diff --git a/md/testy.md b/md/testy.md index ff09b4c..35a8768 100644 --- a/md/testy.md +++ b/md/testy.md @@ -3,10 +3,19 @@ `testy` is a deterministic ACP agent binary for exercising clients against the stable ACP v1 surface. It is built from the `agent-client-protocol-test` crate and communicates over stdio like a normal agent. +The default build enables `agent-client-protocol-test`'s `unstable` cargo feature, which forwards +to the SDK's `unstable` feature: + ```bash cargo build -p agent-client-protocol-test --bin testy ``` +To build stable-only coverage: + +```bash +cargo build -p agent-client-protocol-test --bin testy --no-default-features +``` + The binary lands at `target/debug/testy`. Integration tests that need to spawn it should use `agent_client_protocol_test::test_binaries::testy()` after prebuilding test binaries. @@ -22,13 +31,16 @@ Plain-text commands: - `content` emits prompt/content-focused updates, including every stable `ContentBlock` variant. - `tool_calls` emits tool call create and update flows. - `callbacks` sends every stable agent-to-client request. +- `elicitations` sends only unstable elicitation requests when built with default features. - `cancel_status` reports whether `session/cancel` has been received. - `full` runs all stable scenarios in deterministic order. +With default features, `callbacks` and `full` also run unstable protocol coverage. + JSON command form: ```json -{"command":"run_scenario","scenario":"full"} +{"command":"run_scenario","scenario":"elicitations"} ``` ## Coverage @@ -43,3 +55,8 @@ The `full` scenario sends every stable agent-to-client callback request: `terminal/output`, `terminal/wait_for_exit`, `terminal/kill`, and `terminal/release`. It also emits the stable session update variants, including message chunks, tool calls, plans, available commands, mode/config/session info, and usage. + +With default features, `elicitations`, `callbacks`, and `full` cover `elicitation/create` form mode, +URL mode, session scope, request scope, accept, decline, cancel, and `elicitation/complete`. +If the client advertises form elicitation but not URL elicitation, the URL part returns a +`UrlElicitationRequired` prompt error with deterministic error data. diff --git a/src/agent-client-protocol-test/CHANGELOG.md b/src/agent-client-protocol-test/CHANGELOG.md index 2616dc8..4713822 100644 --- a/src/agent-client-protocol-test/CHANGELOG.md +++ b/src/agent-client-protocol-test/CHANGELOG.md @@ -8,3 +8,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Expand the `testy` binary into a deterministic ACP test agent that can exercise stable v1 agent methods, notifications, session updates, and client callbacks. +- Add default `testy` coverage through the `unstable` cargo feature for elicitation form/URL requests, session/request scopes, response actions, completion notifications, URL-required prompt errors, and a direct `elicitations` prompt trigger. diff --git a/src/agent-client-protocol-test/Cargo.toml b/src/agent-client-protocol-test/Cargo.toml index 49101a8..6b6b4bf 100644 --- a/src/agent-client-protocol-test/Cargo.toml +++ b/src/agent-client-protocol-test/Cargo.toml @@ -9,6 +9,10 @@ homepage.workspace = true description = "Test utilities and mock implementations for the Agent Client Protocol" publish = false +[features] +default = ["unstable"] +unstable = ["agent-client-protocol/unstable"] + [[bin]] name = "mcp-echo-server" path = "src/bin/mcp_echo_server.rs" diff --git a/src/agent-client-protocol-test/src/testy.rs b/src/agent-client-protocol-test/src/testy.rs index 26180aa..55d97d1 100644 --- a/src/agent-client-protocol-test/src/testy.rs +++ b/src/agent-client-protocol-test/src/testy.rs @@ -26,11 +26,20 @@ use agent_client_protocol::schema::v1::{ ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput, UsageUpdate, WriteTextFileRequest, }; +#[cfg(feature = "unstable")] +use agent_client_protocol::schema::v1::{ + CompleteElicitationNotification, CreateElicitationRequest, ElicitationAction, + ElicitationCapabilities, ElicitationFormMode, ElicitationRequestScope, ElicitationSchema, + ElicitationSessionScope, ElicitationUrlMode, ErrorCode, MultiSelectPropertySchema, RequestId, + StringPropertySchema, UrlElicitationRequiredData, UrlElicitationRequiredItem, +}; use agent_client_protocol::{ Agent, Client, ConnectTo, ConnectionTo, JsonRpcRequest, Responder, SentRequest, }; use anyhow::Result; use serde::{Deserialize, Serialize}; +#[cfg(feature = "unstable")] +use std::collections::BTreeMap; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -89,23 +98,30 @@ pub enum TestyScenario { Content, /// Emits tool-call create/update notifications with content, diff, and locations. ToolCalls, - /// Sends every stable agent-to-client request and records client responses or errors. + /// Sends every stable agent-to-client request and any enabled unstable callback coverage. Callbacks, + /// Runs unstable elicitation coverage without the stable callback requests. + #[cfg(feature = "unstable")] + Elicitations, /// Reports whether the session has received a `session/cancel` notification. CancelStatus, - /// Runs all stable scenarios in a deterministic order. + /// Runs all stable scenarios and any enabled unstable coverage in a deterministic order. Full, } impl TestyScenario { - const ALL: [Self; 6] = [ - Self::SessionUpdates, - Self::Content, - Self::ToolCalls, - Self::Callbacks, - Self::CancelStatus, - Self::Full, - ]; + fn all() -> Vec { + vec![ + Self::SessionUpdates, + Self::Content, + Self::ToolCalls, + Self::Callbacks, + #[cfg(feature = "unstable")] + Self::Elicitations, + Self::CancelStatus, + Self::Full, + ] + } fn from_prompt(input: &str) -> Option { match input.trim().to_ascii_lowercase().as_str() { @@ -113,6 +129,8 @@ impl TestyScenario { "content" => Some(Self::Content), "tool_calls" | "tool calls" | "tools" => Some(Self::ToolCalls), "callbacks" | "client_callbacks" | "client callbacks" => Some(Self::Callbacks), + #[cfg(feature = "unstable")] + "elicitations" | "elicitation" | "elicit" => Some(Self::Elicitations), "cancel_status" | "cancel status" | "cancel" => Some(Self::CancelStatus), "full" | "all" => Some(Self::Full), _ => None, @@ -125,6 +143,8 @@ impl TestyScenario { Self::Content => "content", Self::ToolCalls => "tool_calls", Self::Callbacks => "callbacks", + #[cfg(feature = "unstable")] + Self::Elicitations => "elicitations", Self::CancelStatus => "cancel_status", Self::Full => "full", } @@ -154,6 +174,8 @@ struct TestyState { next_message_id: u64, next_tool_call_id: u64, authenticated_methods: HashSet, + #[cfg(feature = "unstable")] + client_elicitation_capabilities: Option, } impl Default for TestyState { @@ -164,6 +186,8 @@ impl Default for TestyState { next_message_id: 1, next_tool_call_id: 1, authenticated_methods: HashSet::new(), + #[cfg(feature = "unstable")] + client_elicitation_capabilities: None, } } } @@ -372,6 +396,33 @@ impl Testy { )] } + fn handle_initialize( + &self, + request: InitializeRequest, + responder: Responder, + ) -> Result<(), agent_client_protocol::Error> { + #[cfg(feature = "unstable")] + { + self.lock_state() + .client_elicitation_capabilities + .clone_from(&request.client_capabilities.elicitation); + } + + responder.respond( + InitializeResponse::new(request.protocol_version) + .agent_capabilities(Testy::agent_capabilities()) + .auth_methods(Testy::auth_methods()), + ) + } + + #[cfg(feature = "unstable")] + fn client_supports_url_elicitation(&self) -> bool { + self.lock_state() + .client_elicitation_capabilities + .as_ref() + .is_some_and(|capabilities| capabilities.url.is_some()) + } + fn handle_authenticate( &self, request: AuthenticateRequest, @@ -585,6 +636,10 @@ impl Testy { Ok(response) => response, Err(error) => { self.finish_prompt(&session_id, StopReason::EndTurn); + #[cfg(feature = "unstable")] + if error.code == ErrorCode::UrlElicitationRequired { + return responder.respond_with_error(error); + } return Err(error); } }; @@ -648,6 +703,20 @@ impl Testy { TestyScenario::Callbacks => { self.exercise_client_callbacks(session_id, connection, report) .await?; + #[cfg(feature = "unstable")] + if !self.is_cancelled(session_id) { + self.exercise_elicitations(session_id, connection, report) + .await?; + } + } + #[cfg(feature = "unstable")] + TestyScenario::Elicitations => { + if self.is_cancelled(session_id) { + report.push("elicitations: cancelled".to_string()); + } else { + self.exercise_elicitations(session_id, connection, report) + .await?; + } } TestyScenario::CancelStatus => { if self.take_cancelled(session_id) { @@ -695,6 +764,11 @@ impl Testy { } self.exercise_client_callbacks(session_id, connection, report) .await?; + #[cfg(feature = "unstable")] + if !self.is_cancelled(session_id) { + self.exercise_elicitations(session_id, connection, report) + .await?; + } if self.take_cancelled(session_id) { report.push("cancel_status: cancelled".to_string()); } else { @@ -736,14 +810,9 @@ impl Testy { send_session_update( connection, session_id, - SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(vec![ - AvailableCommand::new("full", "Run every stable Testy scenario").input( - AvailableCommandInput::Unstructured(UnstructuredCommandInput::new( - "optional scenario arguments", - )), - ), - AvailableCommand::new("callbacks", "Exercise agent-to-client requests"), - ])), + SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new( + available_commands(), + )), )?; send_session_update( connection, @@ -1047,6 +1116,152 @@ impl Testy { } } + #[cfg(feature = "unstable")] + async fn exercise_elicitations( + &self, + session_id: &SessionId, + connection: &ConnectionTo, + report: &mut Vec, + ) -> Result<(), agent_client_protocol::Error> { + let tool_call_id = self.next_tool_call_id("testy-elicit-tool"); + let form_accept = CreateElicitationRequest::new( + ElicitationFormMode::new( + ElicitationSessionScope::new(session_id.clone()) + .tool_call_id(tool_call_id.as_str()), + testy_elicitation_schema(), + ), + "Accept the Testy session-scoped form elicitation", + ); + report.push( + self.request_elicitation_report_until_cancelled( + session_id, + connection, + "elicitation/form_session_accept", + form_accept, + ) + .await, + ); + if elicitation_cancelled(self, session_id, report) { + return Ok(()); + } + + let form_decline = CreateElicitationRequest::new( + ElicitationFormMode::new( + ElicitationSessionScope::new(session_id.clone()), + ElicitationSchema::new().string("reason", false), + ), + "Decline the Testy session-scoped form elicitation", + ); + report.push( + self.request_elicitation_report_until_cancelled( + session_id, + connection, + "elicitation/form_session_decline", + form_decline, + ) + .await, + ); + if elicitation_cancelled(self, session_id, report) { + return Ok(()); + } + + let form_cancel = CreateElicitationRequest::new( + ElicitationFormMode::new( + ElicitationRequestScope::new(RequestId::Str( + "testy-request-form-cancel".to_string(), + )), + ElicitationSchema::new().boolean("confirmed", true), + ), + "Cancel the Testy request-scoped form elicitation", + ); + report.push( + self.request_elicitation_report_until_cancelled( + session_id, + connection, + "elicitation/form_request_cancel", + form_cancel, + ) + .await, + ); + if elicitation_cancelled(self, session_id, report) { + return Ok(()); + } + + if !self.client_supports_url_elicitation() { + return Err(url_elicitation_required_error()); + } + + let session_url_id = "testy-url-session"; + let url_accept = CreateElicitationRequest::new( + ElicitationUrlMode::new( + ElicitationSessionScope::new(session_id.clone()), + session_url_id, + "https://example.com/testy/session", + ), + "Accept the Testy session-scoped URL elicitation", + ); + report.push( + self.request_elicitation_report_until_cancelled( + session_id, + connection, + "elicitation/url_session_accept", + url_accept, + ) + .await, + ); + if elicitation_cancelled(self, session_id, report) { + return Ok(()); + } + connection.send_notification(CompleteElicitationNotification::new(session_url_id))?; + report.push("elicitation/complete_session_url: sent".to_string()); + + let url_decline = CreateElicitationRequest::new( + ElicitationUrlMode::new( + ElicitationRequestScope::new(RequestId::Str( + "testy-request-url-decline".to_string(), + )), + "testy-url-request", + "https://example.com/testy/request", + ), + "Decline the Testy request-scoped URL elicitation", + ); + report.push( + self.request_elicitation_report_until_cancelled( + session_id, + connection, + "elicitation/url_request_decline", + url_decline, + ) + .await, + ); + if elicitation_cancelled(self, session_id, report) { + return Ok(()); + } + + report.push("elicitations: completed".to_string()); + Ok(()) + } + + #[cfg(feature = "unstable")] + async fn request_elicitation_report_until_cancelled( + &self, + session_id: &SessionId, + connection: &ConnectionTo, + label: &str, + request: CreateElicitationRequest, + ) -> String { + match self + .request_result_until_cancelled(session_id, connection, request) + .await + { + Ok(response) => format!( + "{label}: ok {}", + elicitation_action_summary(&response.action) + ), + Err(error) => format!("{label}: error {error:?}"), + } + } + async fn exercise_client_callbacks( &self, session_id: &SessionId, @@ -1438,18 +1653,127 @@ fn parse_command(input_text: &str) -> TestyCommand { } fn help_text() -> String { - let scenarios = TestyScenario::ALL + let unstable = cfg!(feature = "unstable"); + let scenarios = TestyScenario::all() .into_iter() .map(TestyScenario::name) .collect::>() .join(", "); - format!( + let mut text = format!( "Testy commands: help, greet, echo , {scenarios}. JSON command form: {}", TestyCommand::RunScenario { scenario: TestyScenario::Full } .to_prompt() - ) + ); + if unstable { + text.push_str( + ". Unstable mode is enabled: callbacks and full include unstable elicitation coverage", + ); + } + text +} + +fn available_commands() -> Vec { + let unstable = cfg!(feature = "unstable"); + let full_description = if unstable { + "Run every stable and enabled unstable Testy scenario" + } else { + "Run every stable Testy scenario" + }; + let callbacks_description = if unstable { + "Exercise stable and enabled unstable agent-to-client requests" + } else { + "Exercise agent-to-client requests" + }; + + let full_command = + AvailableCommand::new("full", full_description).input(AvailableCommandInput::Unstructured( + UnstructuredCommandInput::new("optional scenario arguments"), + )); + let callbacks_command = AvailableCommand::new("callbacks", callbacks_description); + + #[cfg(feature = "unstable")] + { + vec![ + full_command, + callbacks_command, + AvailableCommand::new("elicitations", "Exercise unstable elicitation requests"), + ] + } + #[cfg(not(feature = "unstable"))] + { + vec![full_command, callbacks_command] + } +} + +#[cfg(feature = "unstable")] +fn testy_elicitation_schema() -> ElicitationSchema { + ElicitationSchema::new() + .title("Testy elicitation form") + .description("Deterministic form covering ACP elicitation field and value shapes") + .string("name", true) + .email("email", false) + .uri("homepage", false) + .date("birthday", false) + .date_time("available_at", false) + .number("confidence", 0.0, 1.0, true) + .integer("age", 0, 120, true) + .boolean("confirmed", true) + .property( + "priority", + StringPropertySchema::new().enum_values(vec![ + "low".to_string(), + "normal".to_string(), + "high".to_string(), + ]), + false, + ) + .property( + "tags", + MultiSelectPropertySchema::new(vec![ + "rust".to_string(), + "acp".to_string(), + "testy".to_string(), + ]), + false, + ) +} + +#[cfg(feature = "unstable")] +fn elicitation_action_summary(action: &ElicitationAction) -> String { + match action { + ElicitationAction::Accept(action) => { + let field_count = action.content.as_ref().map_or(0, BTreeMap::len); + format!("accept content_fields={field_count}") + } + ElicitationAction::Decline => "decline".to_string(), + ElicitationAction::Cancel => "cancel".to_string(), + _ => "unknown".to_string(), + } +} + +#[cfg(feature = "unstable")] +fn elicitation_cancelled(agent: &Testy, session_id: &SessionId, report: &mut Vec) -> bool { + if agent.is_cancelled(session_id) { + report.push("elicitations: cancelled".to_string()); + true + } else { + false + } +} + +#[cfg(feature = "unstable")] +fn url_elicitation_required_error() -> agent_client_protocol::Error { + let data = UrlElicitationRequiredData::new(vec![UrlElicitationRequiredItem::new( + "testy-url-required", + "https://example.com/testy/required", + "Complete the Testy URL elicitation before continuing", + )]); + match serde_json::to_value(data) { + Ok(data) => agent_client_protocol::Error::url_elicitation_required().data(data), + Err(error) => agent_client_protocol::Error::into_internal_error(error), + } } fn send_session_update( @@ -1518,12 +1842,11 @@ impl ConnectTo for Testy { .builder() .name("test-agent") .on_receive_request( - async |initialize: InitializeRequest, responder, _cx| { - responder.respond( - InitializeResponse::new(initialize.protocol_version) - .agent_capabilities(Testy::agent_capabilities()) - .auth_methods(Testy::auth_methods()), - ) + { + let agent = self.clone(); + async move |initialize: InitializeRequest, responder, _cx| { + agent.handle_initialize(initialize, responder) + } }, agent_client_protocol::on_receive_request!(), ) @@ -1753,4 +2076,21 @@ mod tests { ); assert!(!testy.is_cancelled(&session_id)); } + + #[cfg(feature = "unstable")] + #[test] + fn parse_command_accepts_elicitation_prompt_aliases() { + for input in ["elicitations", "elicitation", "elicit"] { + let command = parse_command(input); + assert!( + matches!( + command, + TestyCommand::RunScenario { + scenario: TestyScenario::Elicitations + } + ), + "unexpected command for {input:?}: {command:?}" + ); + } + } } diff --git a/src/agent-client-protocol-test/tests/testy.rs b/src/agent-client-protocol-test/tests/testy.rs index a94b78f..cdfc0bf 100644 --- a/src/agent-client-protocol-test/tests/testy.rs +++ b/src/agent-client-protocol-test/tests/testy.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "unstable")] +use std::collections::BTreeMap; use std::{ collections::{HashMap, HashSet}, path::PathBuf, @@ -5,6 +7,13 @@ use std::{ time::Duration, }; +#[cfg(feature = "unstable")] +use agent_client_protocol::schema::v1::{ + CompleteElicitationNotification, CreateElicitationRequest, CreateElicitationResponse, + ElicitationAcceptAction, ElicitationAction, ElicitationCapabilities, ElicitationContentValue, + ElicitationFormCapabilities, ElicitationMode, ElicitationScope, ElicitationUrlCapabilities, + ErrorCode, UrlElicitationRequiredData, +}; use agent_client_protocol::{ Client, Responder, schema::{ @@ -1297,8 +1306,12 @@ async fn testy_full_scenario_exercises_updates_and_callbacks() let created_terminal_ids = Arc::new(Mutex::new(Vec::::new())); let released_terminal_ids = Arc::new(Mutex::new(Vec::::new())); let terminal_content_ids = Arc::new(Mutex::new(Vec::::new())); + #[cfg(feature = "unstable")] + let elicitation_requests = Arc::new(Mutex::new(Vec::<&'static str>::new())); + #[cfg(feature = "unstable")] + let completed_elicitations = Arc::new(Mutex::new(Vec::::new())); - Client + let builder = Client .builder() .on_receive_notification( { @@ -1448,11 +1461,74 @@ async fn testy_full_scenario_exercises_updates_and_callbacks() } }, agent_client_protocol::on_receive_request!(), + ); + + #[cfg(feature = "unstable")] + let builder = builder + .on_receive_notification( + { + let completed_elicitations = Arc::clone(&completed_elicitations); + async move |notification: CompleteElicitationNotification, _cx| { + completed_elicitations + .lock() + .unwrap() + .push(notification.elicitation_id.to_string()); + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), ) + .on_receive_request( + { + let elicitation_requests = Arc::clone(&elicitation_requests); + async move |request: CreateElicitationRequest, responder, _cx| { + let label = testy_elicitation_request_label(&request); + elicitation_requests.lock().unwrap().push(label); + + let action = match label { + "form_session_accept" => ElicitationAction::Accept( + ElicitationAcceptAction::new().content(BTreeMap::from([ + ("name".to_string(), ElicitationContentValue::from("Ada")), + ("age".to_string(), ElicitationContentValue::from(42_i32)), + ( + "confidence".to_string(), + ElicitationContentValue::from(0.95_f64), + ), + ("confirmed".to_string(), ElicitationContentValue::from(true)), + ( + "tags".to_string(), + ElicitationContentValue::from(vec!["rust", "acp"]), + ), + ])), + ), + "form_session_decline" | "url_request_decline" => { + ElicitationAction::Decline + } + "form_request_cancel" => ElicitationAction::Cancel, + "url_session_accept" => { + ElicitationAction::Accept(ElicitationAcceptAction::new()) + } + other => panic!("unexpected elicitation request label: {other}"), + }; + + responder.respond(CreateElicitationResponse::new(action)) + } + }, + agent_client_protocol::on_receive_request!(), + ); + + builder .connect_with(Testy::new(), async |cx| { - cx.send_request(InitializeRequest::new(ProtocolVersion::V1)) - .block_task() - .await?; + let initialize = InitializeRequest::new(ProtocolVersion::V1); + #[cfg(feature = "unstable")] + let initialize = initialize.client_capabilities( + ClientCapabilities::new().elicitation( + ElicitationCapabilities::new() + .form(ElicitationFormCapabilities::new()) + .url(ElicitationUrlCapabilities::new()), + ), + ); + cx.send_request(initialize).block_task().await?; let session = cx .send_request(NewSessionRequest::new(PathBuf::from("/tmp"))) .block_task() @@ -1513,6 +1589,39 @@ async fn testy_full_scenario_exercises_updates_and_callbacks() assert!(messages.contains("scenario: full")); assert!(messages.contains("terminal/release_for_tool_call: ok")); assert!(messages.contains("terminal/release: ok")); + #[cfg(feature = "unstable")] + for expected in [ + "elicitation/form_session_accept: ok accept content_fields=5", + "elicitation/form_session_decline: ok decline", + "elicitation/form_request_cancel: ok cancel", + "elicitation/url_session_accept: ok accept content_fields=0", + "elicitation/complete_session_url: sent", + "elicitation/url_request_decline: ok decline", + "elicitations: completed", + ] { + assert!( + messages.contains(expected), + "missing report line: {expected}" + ); + } + + #[cfg(feature = "unstable")] + { + assert_eq!( + elicitation_requests.lock().unwrap().as_slice(), + [ + "form_session_accept", + "form_session_decline", + "form_request_cancel", + "url_session_accept", + "url_request_decline", + ] + ); + assert_eq!( + completed_elicitations.lock().unwrap().as_slice(), + ["testy-url-session"] + ); + } let created_terminal_ids = created_terminal_ids.lock().unwrap(); let released_terminal_ids = released_terminal_ids.lock().unwrap(); @@ -1532,6 +1641,420 @@ async fn testy_full_scenario_exercises_updates_and_callbacks() Ok(()) } +#[cfg(feature = "unstable")] +#[tokio::test] +async fn testy_elicitations_prompt_exercises_all_elicitation_create_and_complete_paths() +-> Result<(), agent_client_protocol::Error> { + let agent_messages = Arc::new(Mutex::new(Vec::::new())); + let requests = Arc::new(Mutex::new(Vec::<&'static str>::new())); + let completions = Arc::new(Mutex::new(Vec::::new())); + + Client + .builder() + .on_receive_notification( + { + let agent_messages = Arc::clone(&agent_messages); + async move |notification: SessionNotification, _cx| { + let SessionUpdate::AgentMessageChunk(chunk) = notification.update else { + return Ok(()); + }; + let ContentBlock::Text(text) = chunk.content else { + return Ok(()); + }; + agent_messages.lock().unwrap().push(text.text); + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_notification( + { + let completions = Arc::clone(&completions); + async move |notification: CompleteElicitationNotification, _cx| { + completions + .lock() + .unwrap() + .push(notification.elicitation_id.to_string()); + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_request( + async move |request: RequestPermissionRequest, responder, _cx| { + let option_id = request.options.first().map_or_else( + || PermissionOptionId::new("allow_once"), + |option| option.option_id.clone(), + ); + responder.respond(RequestPermissionResponse::new( + RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id)), + )) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_request: WriteTextFileRequest, responder, _cx| { + responder.respond(WriteTextFileResponse::new()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_request: ReadTextFileRequest, responder, _cx| { + responder.respond(ReadTextFileResponse::new("read by testy client")) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_request: CreateTerminalRequest, responder, _cx| { + responder.respond(CreateTerminalResponse::new("testy-terminal-client")) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_request: TerminalOutputRequest, responder, _cx| { + responder.respond( + TerminalOutputResponse::new("terminal output", false) + .exit_status(TerminalExitStatus::new().exit_code(0)), + ) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_request: WaitForTerminalExitRequest, responder, _cx| { + responder.respond(WaitForTerminalExitResponse::new( + TerminalExitStatus::new().exit_code(0), + )) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_request: KillTerminalRequest, responder, _cx| { + responder.respond(KillTerminalResponse::new()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_request: ReleaseTerminalRequest, responder, _cx| { + responder.respond(ReleaseTerminalResponse::new()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let requests = Arc::clone(&requests); + async move |request: CreateElicitationRequest, responder, _cx| { + let label = testy_elicitation_request_label(&request); + requests.lock().unwrap().push(label); + + let action = match label { + "form_session_accept" => ElicitationAction::Accept( + ElicitationAcceptAction::new().content(BTreeMap::from([ + ("name".to_string(), ElicitationContentValue::from("Ada")), + ("age".to_string(), ElicitationContentValue::from(42_i32)), + ( + "confidence".to_string(), + ElicitationContentValue::from(0.95_f64), + ), + ("confirmed".to_string(), ElicitationContentValue::from(true)), + ( + "tags".to_string(), + ElicitationContentValue::from(vec!["rust", "acp"]), + ), + ])), + ), + "form_session_decline" | "url_request_decline" => { + ElicitationAction::Decline + } + "form_request_cancel" => ElicitationAction::Cancel, + "url_session_accept" => { + ElicitationAction::Accept(ElicitationAcceptAction::new()) + } + other => panic!("unexpected elicitation request label: {other}"), + }; + + responder.respond(CreateElicitationResponse::new(action)) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_with(Testy::new(), async |cx| { + let initialize = InitializeRequest::new(ProtocolVersion::V1).client_capabilities( + ClientCapabilities::new().elicitation( + ElicitationCapabilities::new() + .form(ElicitationFormCapabilities::new()) + .url(ElicitationUrlCapabilities::new()), + ), + ); + cx.send_request(initialize).block_task().await?; + let session = cx + .send_request(NewSessionRequest::new(PathBuf::from("/tmp"))) + .block_task() + .await?; + + let response = cx + .send_request(PromptRequest::new( + session.session_id, + vec!["elicitations".to_string().into()], + )) + .block_task() + .await?; + assert_eq!(response.stop_reason, StopReason::EndTurn); + Ok(()) + }) + .await?; + + assert_eq!( + requests.lock().unwrap().as_slice(), + [ + "form_session_accept", + "form_session_decline", + "form_request_cancel", + "url_session_accept", + "url_request_decline", + ] + ); + assert_eq!( + completions.lock().unwrap().as_slice(), + ["testy-url-session"] + ); + + let messages = agent_messages.lock().unwrap().join("\n"); + for expected in [ + "scenario: elicitations", + "elicitation/form_session_accept: ok accept content_fields=5", + "elicitation/form_session_decline: ok decline", + "elicitation/form_request_cancel: ok cancel", + "elicitation/url_session_accept: ok accept content_fields=0", + "elicitation/complete_session_url: sent", + "elicitation/url_request_decline: ok decline", + "elicitations: completed", + ] { + assert!( + messages.contains(expected), + "missing report line: {expected}" + ); + } + + Ok(()) +} + +#[cfg(feature = "unstable")] +#[tokio::test] +async fn testy_callbacks_with_unstable_feature_returns_url_required_when_url_elicitation_is_unsupported() +-> Result<(), agent_client_protocol::Error> { + let requests = Arc::new(Mutex::new(Vec::<&'static str>::new())); + + Client + .builder() + .on_receive_request( + async move |request: RequestPermissionRequest, responder, _cx| { + let option_id = request.options.first().map_or_else( + || PermissionOptionId::new("allow_once"), + |option| option.option_id.clone(), + ); + responder.respond(RequestPermissionResponse::new( + RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id)), + )) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_request: WriteTextFileRequest, responder, _cx| { + responder.respond(WriteTextFileResponse::new()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_request: ReadTextFileRequest, responder, _cx| { + responder.respond(ReadTextFileResponse::new("read by testy client")) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_request: CreateTerminalRequest, responder, _cx| { + responder.respond(CreateTerminalResponse::new("testy-terminal-client")) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_request: TerminalOutputRequest, responder, _cx| { + responder.respond( + TerminalOutputResponse::new("terminal output", false) + .exit_status(TerminalExitStatus::new().exit_code(0)), + ) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_request: WaitForTerminalExitRequest, responder, _cx| { + responder.respond(WaitForTerminalExitResponse::new( + TerminalExitStatus::new().exit_code(0), + )) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_request: KillTerminalRequest, responder, _cx| { + responder.respond(KillTerminalResponse::new()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_request: ReleaseTerminalRequest, responder, _cx| { + responder.respond(ReleaseTerminalResponse::new()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let requests = Arc::clone(&requests); + async move |request: CreateElicitationRequest, responder, _cx| { + let label = testy_elicitation_request_label(&request); + requests.lock().unwrap().push(label); + let action = match label { + "form_session_accept" => ElicitationAction::Accept( + ElicitationAcceptAction::new().content(BTreeMap::from([ + ("name".to_string(), ElicitationContentValue::from("Ada")), + ("age".to_string(), ElicitationContentValue::from(42_i32)), + ( + "confidence".to_string(), + ElicitationContentValue::from(0.95_f64), + ), + ("confirmed".to_string(), ElicitationContentValue::from(true)), + ])), + ), + "form_session_decline" => ElicitationAction::Decline, + "form_request_cancel" => ElicitationAction::Cancel, + other => panic!("unexpected elicitation request before URL error: {other}"), + }; + responder.respond(CreateElicitationResponse::new(action)) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_with(Testy::new(), async |cx| { + let initialize = InitializeRequest::new(ProtocolVersion::V1).client_capabilities( + ClientCapabilities::new().elicitation( + ElicitationCapabilities::new().form(ElicitationFormCapabilities::new()), + ), + ); + cx.send_request(initialize).block_task().await?; + let session = cx + .send_request(NewSessionRequest::new(PathBuf::from("/tmp"))) + .block_task() + .await?; + + let error = cx + .send_request(PromptRequest::new( + session.session_id, + vec![ + TestyCommand::RunScenario { + scenario: TestyScenario::Callbacks, + } + .to_prompt() + .into(), + ], + )) + .block_task() + .await + .expect_err("url-required elicitation scenario should fail the prompt request"); + assert_eq!(error.code, ErrorCode::UrlElicitationRequired); + + let data: UrlElicitationRequiredData = + serde_json::from_value(error.data.expect("url-required error should include data")) + .expect("url-required error data should deserialize"); + assert_eq!(data.elicitations.len(), 1); + let elicitation = &data.elicitations[0]; + assert_eq!(elicitation.elicitation_id.to_string(), "testy-url-required"); + assert_eq!(elicitation.url, "https://example.com/testy/required"); + assert_eq!( + elicitation.message, + "Complete the Testy URL elicitation before continuing" + ); + Ok(()) + }) + .await?; + + assert_eq!( + requests.lock().unwrap().as_slice(), + [ + "form_session_accept", + "form_session_decline", + "form_request_cancel", + ] + ); + + Ok(()) +} + +#[cfg(feature = "unstable")] +fn testy_elicitation_request_label(request: &CreateElicitationRequest) -> &'static str { + match request.message.as_str() { + "Accept the Testy session-scoped form elicitation" => { + let ElicitationMode::Form(form) = &request.mode else { + panic!("expected form mode for session accept"); + }; + let ElicitationScope::Session(scope) = &form.scope else { + panic!("expected session scope for form accept"); + }; + assert!(scope.tool_call_id.is_some()); + for property in [ + "name", + "email", + "homepage", + "birthday", + "available_at", + "confidence", + "age", + "confirmed", + "priority", + "tags", + ] { + assert!( + form.requested_schema.properties.contains_key(property), + "missing schema property: {property}" + ); + } + "form_session_accept" + } + "Decline the Testy session-scoped form elicitation" => { + assert!(matches!( + &request.mode, + ElicitationMode::Form(form) + if matches!(&form.scope, ElicitationScope::Session(scope) if scope.tool_call_id.is_none()) + )); + "form_session_decline" + } + "Cancel the Testy request-scoped form elicitation" => { + assert!(matches!( + &request.mode, + ElicitationMode::Form(form) + if matches!(&form.scope, ElicitationScope::Request(_)) + )); + "form_request_cancel" + } + "Accept the Testy session-scoped URL elicitation" => { + let ElicitationMode::Url(url) = &request.mode else { + panic!("expected URL mode for session URL accept"); + }; + assert!(matches!(&url.scope, ElicitationScope::Session(_))); + assert_eq!(url.elicitation_id.to_string(), "testy-url-session"); + assert_eq!(url.url, "https://example.com/testy/session"); + "url_session_accept" + } + "Decline the Testy request-scoped URL elicitation" => { + let ElicitationMode::Url(url) = &request.mode else { + panic!("expected URL mode for request URL decline"); + }; + assert!(matches!(&url.scope, ElicitationScope::Request(_))); + assert_eq!(url.elicitation_id.to_string(), "testy-url-request"); + assert_eq!(url.url, "https://example.com/testy/request"); + "url_request_decline" + } + other => panic!("unexpected elicitation request message: {other}"), + } +} + fn assert_all_unique(values: &[String]) { let unique = values.iter().collect::>(); assert_eq!(unique.len(), values.len(), "duplicate values: {values:?}");