diff --git a/agent-client-protocol-schema/src/dispatch.rs b/agent-client-protocol-schema/src/dispatch.rs new file mode 100644 index 000000000..b073a13ee --- /dev/null +++ b/agent-client-protocol-schema/src/dispatch.rs @@ -0,0 +1,484 @@ +//! Method-aware deserialization for the protocol request, notification, and +//! response enums. +//! +//! The wire enums ([`v1::ClientRequest`], [`v1::AgentRequest`], +//! [`v1::ClientResponse`], [`v1::AgentResponse`], [`v1::ClientNotification`], +//! [`v1::AgentNotification`], [`v1::ProtocolLevelNotification`], and their +//! [`v2`] counterparts) are `#[serde(untagged)]` so that the JSON schema +//! generator can describe each variant's payload shape. That representation +//! is **not** suitable for parsing incoming messages: serde tries the +//! variants in declaration order, so any variant whose fields all happen to +//! be optional matches *every* payload, silently dropping the data that +//! belonged to the intended variant. For example, a `session/new` request +//! used to deserialize as [`v1::ClientRequest::LogoutRequest`] with an empty +//! payload instead of failing or routing correctly. +//! +//! The `from_method_and_params` / `from_method_and_result` helpers added here +//! dispatch on the JSON-RPC method name first and then deserialize the +//! payload into the matching variant, mirroring how conformant +//! implementations route messages. Extension methods (names starting with +//! `_`) are routed to the `Ext*` variants with their raw payload preserved. + +/// Error returned by the `from_method_and_params` and `from_method_and_result` +/// deserialization helpers. +#[derive(Debug)] +#[non_exhaustive] +pub enum MethodDispatchError { + /// The method name is not part of the protocol and, where the enum has an + /// extension variant, does not start with the extension prefix `_`. + UnknownMethod { + /// The unrecognized method name. + method: String, + }, + /// The payload did not match the shape required by the method's variant. + InvalidPayload { + /// The method that was dispatched. + method: String, + /// The underlying deserialization error. + source: serde_json::Error, + }, +} + +impl std::fmt::Display for MethodDispatchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnknownMethod { method } => { + write!(f, "unknown or unsupported method `{method}`") + } + Self::InvalidPayload { method, source } => { + write!(f, "invalid payload for method `{method}`: {source}") + } + } + } +} + +impl std::error::Error for MethodDispatchError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::UnknownMethod { .. } => None, + Self::InvalidPayload { source, .. } => Some(source), + } + } +} + +macro_rules! impl_acp_from_method_and_params { + ( + enum = $Enum:ty, + $(ext = { $ExtVariant:ident, $ext_wrap:ident, $ExtCtor:path },)? + arms = { $( $(#[$meta:meta])* $Variant:ident ($wrap:ident) => $method:expr ),* $(,)? } + ) => { + impl $Enum { + /// Deserializes a wire payload by dispatching on the JSON-RPC + /// method name. + /// + /// Unlike the derived `Deserialize` implementation (which is + /// `#[serde(untagged)]` and exists for schema generation), this + /// never silently selects the wrong variant: unknown non-extension + /// methods fail with [`MethodDispatchError::UnknownMethod`], and + /// payloads that do not match the method's shape fail with + /// [`MethodDispatchError::InvalidPayload`]. + /// + /// # Errors + /// + /// Returns [`MethodDispatchError::UnknownMethod`] for method names + /// that are not part of the protocol and do not start with the + /// extension prefix `_`, and [`MethodDispatchError::InvalidPayload`] + /// when the payload does not match the dispatched variant's shape. + /// + /// `params` is the optional `params` member of the JSON-RPC + /// request or notification; `None` and `null` are treated as an + /// empty object. Extension methods (names starting with `_`) are + /// routed to the enum's extension variant with the raw payload + /// preserved. + pub fn from_method_and_params( + method: &str, + params: Option, + ) -> ::std::result::Result { + match method { + $( + $(#[$meta])* + m if m == $method => ::serde_json::from_value( + params.filter(|p| !p.is_null()).unwrap_or_else(|| ::serde_json::json!({})), + ) + .map(|payload| Self::$Variant(impl_acp_from_method_and_params!(@wrap $wrap payload))) + .map_err(|source| $crate::dispatch::MethodDispatchError::InvalidPayload { + method: method.to_owned(), + source, + }), + )* + _ => { + $( + if method.starts_with('_') { + let raw_params = params.unwrap_or(::serde_json::Value::Null); + return ::serde_json::value::RawValue::from_string(raw_params.to_string()) + .map_err(|source| $crate::dispatch::MethodDispatchError::InvalidPayload { + method: method.to_owned(), + source, + }) + .map(|raw| { + Self::$ExtVariant(impl_acp_from_method_and_params!(@wrap $ext_wrap ($ExtCtor( + ::std::sync::Arc::from(method), + ::std::sync::Arc::from(raw), + )))) + }); + } + )? + Err($crate::dispatch::MethodDispatchError::UnknownMethod { + method: method.to_owned(), + }) + } + } + } + } + }; + (@wrap plain $payload:expr) => { + $payload + }; + (@wrap boxed $payload:expr) => { + Box::new($payload) + }; +} + +macro_rules! impl_acp_from_method_and_result { + ( + enum = $Enum:ty, + ext = { $ExtVariant:ident, $ext_wrap:ident, $ExtCtor:path }, + arms = { $( $(#[$meta:meta])* $Variant:ident ($wrap:ident) => $method:expr ),* $(,)? } + ) => { + impl $Enum { + /// Deserializes the `result` payload of a successful JSON-RPC + /// response by dispatching on the method name of the originating + /// request. + /// + /// Unlike the derived `Deserialize` implementation (which is + /// `#[serde(untagged)]` and exists for schema generation), this + /// never silently selects the wrong variant: unknown non-extension + /// methods fail with [`MethodDispatchError::UnknownMethod`], and + /// payloads that do not match the response's shape fail with + /// [`MethodDispatchError::InvalidPayload`]. + /// + /// # Errors + /// + /// Returns [`MethodDispatchError::UnknownMethod`] for method names + /// that are not part of the protocol and do not start with the + /// extension prefix `_`, and [`MethodDispatchError::InvalidPayload`] + /// when the payload does not match the dispatched variant's shape. + /// + /// `result` is the optional `result` member of the JSON-RPC + /// response; `None` and `null` are treated as an empty object. + /// Extension methods (names starting with `_`) are routed to the + /// enum's extension variant with the raw payload preserved. + pub fn from_method_and_result( + method: &str, + result: Option, + ) -> ::std::result::Result { + match method { + $( + $(#[$meta])* + m if m == $method => ::serde_json::from_value( + result.filter(|p| !p.is_null()).unwrap_or_else(|| ::serde_json::json!({})), + ) + .map(|payload| Self::$Variant(impl_acp_from_method_and_result!(@wrap $wrap payload))) + .map_err(|source| $crate::dispatch::MethodDispatchError::InvalidPayload { + method: method.to_owned(), + source, + }), + )* + _ => { + if method.starts_with('_') { + let raw_result = result.unwrap_or(::serde_json::Value::Null); + return ::serde_json::value::RawValue::from_string(raw_result.to_string()) + .map_err(|source| $crate::dispatch::MethodDispatchError::InvalidPayload { + method: method.to_owned(), + source, + }) + .map(|raw| { + Self::$ExtVariant(impl_acp_from_method_and_result!(@wrap $ext_wrap ($ExtCtor( + ::std::sync::Arc::from(raw), + )))) + }); + } + Err($crate::dispatch::MethodDispatchError::UnknownMethod { + method: method.to_owned(), + }) + } + } + } + } + }; + (@wrap plain $payload:expr) => { + $payload + }; + (@wrap boxed $payload:expr) => { + Box::new($payload) + }; +} + +impl_acp_from_method_and_params! { + enum = crate::v1::ClientRequest, + ext = { ExtMethodRequest, plain, crate::v1::ExtRequest::new }, + arms = { + InitializeRequest (plain) => crate::v1::AGENT_METHOD_NAMES.initialize, + AuthenticateRequest (plain) => crate::v1::AGENT_METHOD_NAMES.authenticate, + #[cfg(feature = "unstable_llm_providers")] + ListProvidersRequest (plain) => crate::v1::AGENT_METHOD_NAMES.providers_list, + #[cfg(feature = "unstable_llm_providers")] + SetProviderRequest (plain) => crate::v1::AGENT_METHOD_NAMES.providers_set, + #[cfg(feature = "unstable_llm_providers")] + DisableProviderRequest (plain) => crate::v1::AGENT_METHOD_NAMES.providers_disable, + LogoutRequest (plain) => crate::v1::AGENT_METHOD_NAMES.logout, + #[cfg(feature = "unstable_auth_status")] + AuthStatusRequest (plain) => crate::v1::AGENT_METHOD_NAMES.auth_status, + NewSessionRequest (plain) => crate::v1::AGENT_METHOD_NAMES.session_new, + LoadSessionRequest (plain) => crate::v1::AGENT_METHOD_NAMES.session_load, + ListSessionsRequest (plain) => crate::v1::AGENT_METHOD_NAMES.session_list, + DeleteSessionRequest (plain) => crate::v1::AGENT_METHOD_NAMES.session_delete, + #[cfg(feature = "unstable_session_fork")] + ForkSessionRequest (plain) => crate::v1::AGENT_METHOD_NAMES.session_fork, + #[cfg(feature = "unstable_session_archive")] + ArchiveSessionRequest (plain) => crate::v1::AGENT_METHOD_NAMES.session_archive, + #[cfg(feature = "unstable_session_archive")] + UnarchiveSessionRequest (plain) => crate::v1::AGENT_METHOD_NAMES.session_unarchive, + #[cfg(feature = "unstable_session_status")] + SessionStatusRequest (plain) => crate::v1::AGENT_METHOD_NAMES.session_status, + #[cfg(feature = "unstable_session_set_title")] + SetSessionTitleRequest (plain) => crate::v1::AGENT_METHOD_NAMES.session_set_title, + ResumeSessionRequest (plain) => crate::v1::AGENT_METHOD_NAMES.session_resume, + CloseSessionRequest (plain) => crate::v1::AGENT_METHOD_NAMES.session_close, + SetSessionModeRequest (plain) => crate::v1::AGENT_METHOD_NAMES.session_set_mode, + SetSessionConfigOptionRequest (plain) => crate::v1::AGENT_METHOD_NAMES.session_set_config_option, + PromptRequest (plain) => crate::v1::AGENT_METHOD_NAMES.session_prompt, + } +} + +impl_acp_from_method_and_params! { + enum = crate::v1::AgentRequest, + ext = { ExtMethodRequest, plain, crate::v1::ExtRequest::new }, + arms = { + WriteTextFileRequest (plain) => crate::v1::CLIENT_METHOD_NAMES.fs_write_text_file, + ReadTextFileRequest (plain) => crate::v1::CLIENT_METHOD_NAMES.fs_read_text_file, + RequestPermissionRequest (plain) => crate::v1::CLIENT_METHOD_NAMES.session_request_permission, + CreateTerminalRequest (plain) => crate::v1::CLIENT_METHOD_NAMES.terminal_create, + TerminalOutputRequest (plain) => crate::v1::CLIENT_METHOD_NAMES.terminal_output, + ReleaseTerminalRequest (plain) => crate::v1::CLIENT_METHOD_NAMES.terminal_release, + WaitForTerminalExitRequest (plain) => crate::v1::CLIENT_METHOD_NAMES.terminal_wait_for_exit, + KillTerminalRequest (plain) => crate::v1::CLIENT_METHOD_NAMES.terminal_kill, + CreateElicitationRequest (plain) => crate::v1::CLIENT_METHOD_NAMES.elicitation_create, + #[cfg(feature = "unstable_mcp_over_acp")] + MessageMcpRequest (plain) => crate::v1::CLIENT_METHOD_NAMES.mcp_message, + } +} + +impl_acp_from_method_and_params! { + enum = crate::v1::ClientNotification, + ext = { ExtNotification, plain, crate::v1::ExtNotification::new }, + arms = { + CancelNotification (plain) => crate::v1::AGENT_METHOD_NAMES.session_cancel, + #[cfg(feature = "unstable_mcp_over_acp")] + MessageMcpNotification (plain) => crate::v1::AGENT_METHOD_NAMES.mcp_message, + } +} + +impl_acp_from_method_and_params! { + enum = crate::v1::AgentNotification, + ext = { ExtNotification, plain, crate::v1::ExtNotification::new }, + arms = { + SessionNotification (plain) => crate::v1::CLIENT_METHOD_NAMES.session_update, + CompleteElicitationNotification (plain) => crate::v1::CLIENT_METHOD_NAMES.elicitation_complete, + #[cfg(feature = "unstable_auth_status")] + AuthStatusUpdateNotification (plain) => crate::v1::CLIENT_METHOD_NAMES.auth_status_update, + } +} + +impl_acp_from_method_and_params! { + enum = crate::v1::ProtocolLevelNotification, + arms = { + CancelRequestNotification (plain) => crate::v1::PROTOCOL_LEVEL_METHOD_NAMES.cancel_request, + } +} + +impl_acp_from_method_and_result! { + enum = crate::v1::AgentResponse, + ext = { ExtMethodResponse, plain, crate::v1::ExtResponse::new }, + arms = { + InitializeResponse (plain) => crate::v1::AGENT_METHOD_NAMES.initialize, + AuthenticateResponse (plain) => crate::v1::AGENT_METHOD_NAMES.authenticate, + #[cfg(feature = "unstable_llm_providers")] + ListProvidersResponse (plain) => crate::v1::AGENT_METHOD_NAMES.providers_list, + #[cfg(feature = "unstable_llm_providers")] + SetProviderResponse (plain) => crate::v1::AGENT_METHOD_NAMES.providers_set, + #[cfg(feature = "unstable_llm_providers")] + DisableProviderResponse (plain) => crate::v1::AGENT_METHOD_NAMES.providers_disable, + LogoutResponse (plain) => crate::v1::AGENT_METHOD_NAMES.logout, + #[cfg(feature = "unstable_auth_status")] + AuthStatusResponse (plain) => crate::v1::AGENT_METHOD_NAMES.auth_status, + NewSessionResponse (plain) => crate::v1::AGENT_METHOD_NAMES.session_new, + LoadSessionResponse (plain) => crate::v1::AGENT_METHOD_NAMES.session_load, + ListSessionsResponse (plain) => crate::v1::AGENT_METHOD_NAMES.session_list, + DeleteSessionResponse (plain) => crate::v1::AGENT_METHOD_NAMES.session_delete, + #[cfg(feature = "unstable_session_fork")] + ForkSessionResponse (plain) => crate::v1::AGENT_METHOD_NAMES.session_fork, + #[cfg(feature = "unstable_session_archive")] + ArchiveSessionResponse (plain) => crate::v1::AGENT_METHOD_NAMES.session_archive, + #[cfg(feature = "unstable_session_archive")] + UnarchiveSessionResponse (plain) => crate::v1::AGENT_METHOD_NAMES.session_unarchive, + #[cfg(feature = "unstable_session_status")] + SessionStatusResponse (plain) => crate::v1::AGENT_METHOD_NAMES.session_status, + #[cfg(feature = "unstable_session_set_title")] + SetSessionTitleResponse (plain) => crate::v1::AGENT_METHOD_NAMES.session_set_title, + ResumeSessionResponse (plain) => crate::v1::AGENT_METHOD_NAMES.session_resume, + CloseSessionResponse (plain) => crate::v1::AGENT_METHOD_NAMES.session_close, + SetSessionModeResponse (plain) => crate::v1::AGENT_METHOD_NAMES.session_set_mode, + SetSessionConfigOptionResponse (plain) => crate::v1::AGENT_METHOD_NAMES.session_set_config_option, + PromptResponse (plain) => crate::v1::AGENT_METHOD_NAMES.session_prompt, + } +} + +impl_acp_from_method_and_result! { + enum = crate::v1::ClientResponse, + ext = { ExtMethodResponse, plain, crate::v1::ExtResponse::new }, + arms = { + WriteTextFileResponse (plain) => crate::v1::CLIENT_METHOD_NAMES.fs_write_text_file, + ReadTextFileResponse (plain) => crate::v1::CLIENT_METHOD_NAMES.fs_read_text_file, + RequestPermissionResponse (plain) => crate::v1::CLIENT_METHOD_NAMES.session_request_permission, + CreateTerminalResponse (plain) => crate::v1::CLIENT_METHOD_NAMES.terminal_create, + TerminalOutputResponse (plain) => crate::v1::CLIENT_METHOD_NAMES.terminal_output, + ReleaseTerminalResponse (plain) => crate::v1::CLIENT_METHOD_NAMES.terminal_release, + WaitForTerminalExitResponse (plain) => crate::v1::CLIENT_METHOD_NAMES.terminal_wait_for_exit, + KillTerminalResponse (plain) => crate::v1::CLIENT_METHOD_NAMES.terminal_kill, + CreateElicitationResponse (plain) => crate::v1::CLIENT_METHOD_NAMES.elicitation_create, + #[cfg(feature = "unstable_mcp_over_acp")] + MessageMcpResponse (plain) => crate::v1::CLIENT_METHOD_NAMES.mcp_message, + } +} + +#[cfg(feature = "unstable_protocol_v2")] +impl_acp_from_method_and_params! { + enum = crate::v2::ClientRequest, + ext = { ExtMethodRequest, boxed, crate::v2::ExtRequest::new }, + arms = { + InitializeRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.initialize, + LoginAuthRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.auth_login, + #[cfg(feature = "unstable_llm_providers")] + ListProvidersRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.providers_list, + #[cfg(feature = "unstable_llm_providers")] + SetProviderRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.providers_set, + #[cfg(feature = "unstable_llm_providers")] + DisableProviderRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.providers_disable, + LogoutAuthRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.auth_logout, + #[cfg(feature = "unstable_auth_status")] + AuthStatusRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.auth_status, + NewSessionRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.session_new, + ListSessionsRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.session_list, + DeleteSessionRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.session_delete, + #[cfg(feature = "unstable_session_fork")] + ForkSessionRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.session_fork, + #[cfg(feature = "unstable_session_archive")] + ArchiveSessionRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.session_archive, + #[cfg(feature = "unstable_session_archive")] + UnarchiveSessionRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.session_unarchive, + #[cfg(feature = "unstable_session_status")] + SessionStatusRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.session_status, + #[cfg(feature = "unstable_session_set_title")] + SetSessionTitleRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.session_set_title, + ResumeSessionRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.session_resume, + CloseSessionRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.session_close, + CancelToolCallRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.tool_call_cancel, + CancelTerminalRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.terminal_cancel, + SetSessionConfigOptionRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.session_set_config_option, + PromptRequest (boxed) => crate::v2::AGENT_METHOD_NAMES.session_prompt, + } +} + +#[cfg(feature = "unstable_protocol_v2")] +impl_acp_from_method_and_params! { + enum = crate::v2::AgentRequest, + ext = { ExtMethodRequest, boxed, crate::v2::ExtRequest::new }, + arms = { + RequestPermissionRequest (boxed) => crate::v2::CLIENT_METHOD_NAMES.session_request_permission, + CreateElicitationRequest (boxed) => crate::v2::CLIENT_METHOD_NAMES.elicitation_create, + #[cfg(feature = "unstable_mcp_over_acp")] + MessageMcpRequest (boxed) => crate::v2::CLIENT_METHOD_NAMES.mcp_message, + } +} + +#[cfg(feature = "unstable_protocol_v2")] +impl_acp_from_method_and_params! { + enum = crate::v2::ClientNotification, + ext = { ExtNotification, boxed, crate::v2::ExtNotification::new }, + arms = { + CancelSessionNotification (boxed) => crate::v2::AGENT_METHOD_NAMES.session_cancel, + #[cfg(feature = "unstable_mcp_over_acp")] + MessageMcpNotification (boxed) => crate::v2::AGENT_METHOD_NAMES.mcp_message, + } +} + +#[cfg(feature = "unstable_protocol_v2")] +impl_acp_from_method_and_params! { + enum = crate::v2::AgentNotification, + ext = { ExtNotification, boxed, crate::v2::ExtNotification::new }, + arms = { + UpdateSessionNotification (boxed) => crate::v2::CLIENT_METHOD_NAMES.session_update, + CompleteElicitationNotification (boxed) => crate::v2::CLIENT_METHOD_NAMES.elicitation_complete, + #[cfg(feature = "unstable_auth_status")] + AuthStatusUpdateNotification (boxed) => crate::v2::CLIENT_METHOD_NAMES.auth_status_update, + } +} + +#[cfg(feature = "unstable_protocol_v2")] +impl_acp_from_method_and_params! { + enum = crate::v2::ProtocolLevelNotification, + arms = { + CancelRequestNotification (plain) => crate::v2::PROTOCOL_LEVEL_METHOD_NAMES.cancel_request, + } +} + +#[cfg(feature = "unstable_protocol_v2")] +impl_acp_from_method_and_result! { + enum = crate::v2::AgentResponse, + ext = { ExtMethodResponse, boxed, crate::v2::ExtResponse::new }, + arms = { + InitializeResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.initialize, + LoginAuthResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.auth_login, + #[cfg(feature = "unstable_llm_providers")] + ListProvidersResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.providers_list, + #[cfg(feature = "unstable_llm_providers")] + SetProviderResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.providers_set, + #[cfg(feature = "unstable_llm_providers")] + DisableProviderResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.providers_disable, + LogoutAuthResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.auth_logout, + #[cfg(feature = "unstable_auth_status")] + AuthStatusResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.auth_status, + NewSessionResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.session_new, + ListSessionsResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.session_list, + DeleteSessionResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.session_delete, + #[cfg(feature = "unstable_session_fork")] + ForkSessionResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.session_fork, + #[cfg(feature = "unstable_session_archive")] + ArchiveSessionResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.session_archive, + #[cfg(feature = "unstable_session_archive")] + UnarchiveSessionResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.session_unarchive, + #[cfg(feature = "unstable_session_status")] + SessionStatusResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.session_status, + #[cfg(feature = "unstable_session_set_title")] + SetSessionTitleResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.session_set_title, + ResumeSessionResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.session_resume, + CloseSessionResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.session_close, + CancelToolCallResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.tool_call_cancel, + CancelTerminalResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.terminal_cancel, + SetSessionConfigOptionResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.session_set_config_option, + PromptResponse (boxed) => crate::v2::AGENT_METHOD_NAMES.session_prompt, + } +} + +#[cfg(feature = "unstable_protocol_v2")] +impl_acp_from_method_and_result! { + enum = crate::v2::ClientResponse, + ext = { ExtMethodResponse, boxed, crate::v2::ExtResponse::new }, + arms = { + RequestPermissionResponse (boxed) => crate::v2::CLIENT_METHOD_NAMES.session_request_permission, + CreateElicitationResponse (boxed) => crate::v2::CLIENT_METHOD_NAMES.elicitation_create, + #[cfg(feature = "unstable_mcp_over_acp")] + MessageMcpResponse (boxed) => crate::v2::CLIENT_METHOD_NAMES.mcp_message, + } +} diff --git a/agent-client-protocol-schema/src/lib.rs b/agent-client-protocol-schema/src/lib.rs index 8d765aeef..f847be683 100644 --- a/agent-client-protocol-schema/src/lib.rs +++ b/agent-client-protocol-schema/src/lib.rs @@ -45,6 +45,7 @@ //! For the complete protocol specification and documentation, visit //! . +mod dispatch; pub mod rpc; mod serde_util; pub mod v1; @@ -52,6 +53,7 @@ pub mod v1; pub mod v2; mod version; +pub use dispatch::MethodDispatchError; pub use serde_util::{IntoMaybeUndefined, IntoOption, MaybeUndefined}; pub use version::*; diff --git a/agent-client-protocol-schema/src/v1/agent.rs b/agent-client-protocol-schema/src/v1/agent.rs index b6bc45eff..cdf33d52b 100644 --- a/agent-client-protocol-schema/src/v1/agent.rs +++ b/agent-client-protocol-schema/src/v1/agent.rs @@ -6403,6 +6403,13 @@ pub(crate) const AUTH_STATUS_METHOD_NAME: &str = "auth/status"; /// to use this directly. /// /// This enum encompasses all method calls from client to agent. +/// +/// # Deserialization +/// +/// Do not parse incoming messages with the derived untagged `Deserialize` +/// (it exists for schema generation): variants are tried in order, so +/// all-optional variants silently swallow any payload. Use +/// [`ClientRequest::from_method_and_params`]. #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] @@ -6655,6 +6662,13 @@ impl ClientRequest { /// to use this directly - the responses are handled automatically by the connection. /// /// These are responses to the corresponding `ClientRequest` variants. +/// +/// # Deserialization +/// +/// Do not parse incoming messages with the derived untagged `Deserialize` +/// (it exists for schema generation): variants are tried in order, so +/// all-optional variants silently swallow any payload. Use +/// [`AgentResponse::from_method_and_result`]. #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] @@ -6723,6 +6737,13 @@ pub enum AgentResponse { /// to use this directly. /// /// Notifications do not expect a response. +/// +/// # Deserialization +/// +/// Do not parse incoming messages with the derived untagged `Deserialize` +/// (it exists for schema generation): variants are tried in order, so +/// all-optional variants silently swallow any payload. Use +/// [`ClientNotification::from_method_and_params`]. #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] diff --git a/agent-client-protocol-schema/src/v1/client.rs b/agent-client-protocol-schema/src/v1/client.rs index ba2233a49..51159a173 100644 --- a/agent-client-protocol-schema/src/v1/client.rs +++ b/agent-client-protocol-schema/src/v1/client.rs @@ -3992,6 +3992,13 @@ pub(crate) const AUTH_STATUS_UPDATE_NOTIFICATION: &str = "auth/status_update"; /// to use this directly. /// /// This enum encompasses all method calls from agent to client. +/// +/// # Deserialization +/// +/// Do not parse incoming messages with the derived untagged `Deserialize` +/// (it exists for schema generation): variants are tried in order, so +/// all-optional variants silently swallow any payload. Use +/// [`AgentRequest::from_method_and_params`]. #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] @@ -4124,6 +4131,13 @@ impl AgentRequest { /// to use this directly - the responses are handled automatically by the connection. /// /// These are responses to the corresponding `AgentRequest` variants. +/// +/// # Deserialization +/// +/// Do not parse incoming messages with the derived untagged `Deserialize` +/// (it exists for schema generation): variants are tried in order, so +/// all-optional variants silently swallow any payload. Use +/// [`ClientResponse::from_method_and_result`]. #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] @@ -4161,6 +4175,13 @@ pub enum ClientResponse { /// to use this directly. /// /// Notifications do not expect a response. +/// +/// # Deserialization +/// +/// Do not parse incoming messages with the derived untagged `Deserialize` +/// (it exists for schema generation): variants are tried in order, so +/// all-optional variants silently swallow any payload. Use +/// [`AgentNotification::from_method_and_params`]. #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] diff --git a/agent-client-protocol-schema/src/v1/protocol_level.rs b/agent-client-protocol-schema/src/v1/protocol_level.rs index 0e29f13ec..3f03f9c9b 100644 --- a/agent-client-protocol-schema/src/v1/protocol_level.rs +++ b/agent-client-protocol-schema/src/v1/protocol_level.rs @@ -83,6 +83,13 @@ pub(crate) const CANCEL_REQUEST_METHOD_NAME: &str = "$/cancel_request"; /// notifications starting with '$/' it is free to ignore the notification. /// /// Notifications do not expect a response. +/// +/// # Deserialization +/// +/// Do not parse incoming messages with the derived untagged `Deserialize` +/// (it exists for schema generation): variants are tried in order, so +/// all-optional variants silently swallow any payload. Use +/// [`ProtocolLevelNotification::from_method_and_params`]. #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] diff --git a/agent-client-protocol-schema/src/v2/agent.rs b/agent-client-protocol-schema/src/v2/agent.rs index d8d2487f0..4dd5d1267 100644 --- a/agent-client-protocol-schema/src/v2/agent.rs +++ b/agent-client-protocol-schema/src/v2/agent.rs @@ -7129,6 +7129,13 @@ pub(crate) const AUTH_STATUS_METHOD_NAME: &str = "auth/status"; /// to use this directly. /// /// This enum encompasses all method calls from client to agent. +/// +/// # Deserialization +/// +/// Do not parse incoming messages with the derived untagged `Deserialize` +/// (it exists for schema generation): variants are tried in order, so +/// all-optional variants silently swallow any payload. Use +/// [`ClientRequest::from_method_and_params`]. #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] @@ -7384,6 +7391,13 @@ impl ClientRequest { /// to use this directly - the responses are handled automatically by the connection. /// /// These are responses to the corresponding `ClientRequest` variants. +/// +/// # Deserialization +/// +/// Do not parse incoming messages with the derived untagged `Deserialize` +/// (it exists for schema generation): variants are tried in order, so +/// all-optional variants silently swallow any payload. Use +/// [`AgentResponse::from_method_and_result`]. #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] @@ -7451,6 +7465,13 @@ pub enum AgentResponse { /// to use this directly. /// /// Notifications do not expect a response. +/// +/// # Deserialization +/// +/// Do not parse incoming messages with the derived untagged `Deserialize` +/// (it exists for schema generation): variants are tried in order, so +/// all-optional variants silently swallow any payload. Use +/// [`ClientNotification::from_method_and_params`]. #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] diff --git a/agent-client-protocol-schema/src/v2/client.rs b/agent-client-protocol-schema/src/v2/client.rs index 9021d3510..efae48007 100644 --- a/agent-client-protocol-schema/src/v2/client.rs +++ b/agent-client-protocol-schema/src/v2/client.rs @@ -3965,6 +3965,13 @@ pub(crate) const AUTH_STATUS_UPDATE_NOTIFICATION: &str = "auth/status_update"; /// to use this directly. /// /// This enum encompasses all method calls from agent to client. +/// +/// # Deserialization +/// +/// Do not parse incoming messages with the derived untagged `Deserialize` +/// (it exists for schema generation): variants are tried in order, so +/// all-optional variants silently swallow any payload. Use +/// [`AgentRequest::from_method_and_params`]. #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] @@ -4023,6 +4030,13 @@ impl AgentRequest { /// to use this directly - the responses are handled automatically by the connection. /// /// These are responses to the corresponding `AgentRequest` variants. +/// +/// # Deserialization +/// +/// Do not parse incoming messages with the derived untagged `Deserialize` +/// (it exists for schema generation): variants are tried in order, so +/// all-optional variants silently swallow any payload. Use +/// [`ClientResponse::from_method_and_result`]. #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] @@ -4046,6 +4060,13 @@ pub enum ClientResponse { /// to use this directly. /// /// Notifications do not expect a response. +/// +/// # Deserialization +/// +/// Do not parse incoming messages with the derived untagged `Deserialize` +/// (it exists for schema generation): variants are tried in order, so +/// all-optional variants silently swallow any payload. Use +/// [`AgentNotification::from_method_and_params`]. #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] diff --git a/agent-client-protocol-schema/src/v2/protocol_level.rs b/agent-client-protocol-schema/src/v2/protocol_level.rs index a2ff093c9..35b79ec74 100644 --- a/agent-client-protocol-schema/src/v2/protocol_level.rs +++ b/agent-client-protocol-schema/src/v2/protocol_level.rs @@ -83,6 +83,13 @@ pub(crate) const CANCEL_REQUEST_METHOD_NAME: &str = "$/cancel_request"; /// notifications starting with '$/' it is free to ignore the notification. /// /// Notifications do not expect a response. +/// +/// # Deserialization +/// +/// Do not parse incoming messages with the derived untagged `Deserialize` +/// (it exists for schema generation): variants are tried in order, so +/// all-optional variants silently swallow any payload. Use +/// [`ProtocolLevelNotification::from_method_and_params`]. #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] diff --git a/agent-client-protocol-schema/tests/method_dispatch.rs b/agent-client-protocol-schema/tests/method_dispatch.rs new file mode 100644 index 000000000..dd7d41a5d --- /dev/null +++ b/agent-client-protocol-schema/tests/method_dispatch.rs @@ -0,0 +1,555 @@ +//! Tests for method-aware deserialization of the protocol enums. +//! +//! The enums' derived `Deserialize` implementations are `#[serde(untagged)]` +//! and exist for schema generation: serde tries variants in declaration +//! order, so a variant with all-optional fields silently matches any payload. +//! The `from_method_and_params` / `from_method_and_result` helpers dispatch +//! on the JSON-RPC method name instead and must route every documented +//! method to its intended variant. + +use agent_client_protocol_schema::{MethodDispatchError, v1}; +use serde_json::json; + +#[test] +fn v1_session_new_routes_to_new_session_request() { + // Regression test: with the derived untagged deserialization this exact + // payload silently parsed as `ClientRequest::LogoutRequest`, dropping + // `cwd` and `mcpServers` without any error. + let parsed = v1::ClientRequest::from_method_and_params( + "session/new", + Some(json!({ + "cwd": "/home/user/project", + "mcpServers": [{"name": "filesystem", "command": "/path/to/mcp-server", "args": ["--stdio"], "env": []}], + })), + ) + .unwrap(); + match parsed { + v1::ClientRequest::NewSessionRequest(req) => { + assert_eq!(req.cwd, std::path::PathBuf::from("/home/user/project")); + assert_eq!(req.mcp_servers.len(), 1); + } + other => panic!("expected NewSessionRequest, got {}", other.method()), + } +} + +#[test] +fn v1_stable_methods_route_to_their_variant() { + // Client-to-agent methods. + let client_cases: &[(&str, serde_json::Value)] = &[ + ("initialize", json!({"protocolVersion": 1})), + ("authenticate", json!({"methodId": "oauth"})), + ("logout", json!({})), + ("session/new", json!({"cwd": "/tmp", "mcpServers": []})), + ( + "session/load", + json!({"sessionId": "s1", "cwd": "/tmp", "mcpServers": []}), + ), + ("session/list", json!({})), + ("session/delete", json!({"sessionId": "s1"})), + ("session/close", json!({"sessionId": "s1"})), + ("session/resume", json!({"sessionId": "s1", "cwd": "/tmp"})), + ( + "session/set_mode", + json!({"sessionId": "s1", "modeId": "code"}), + ), + ( + "session/set_config_option", + json!({"sessionId": "s1", "configId": "mode", "type": "id", "value": "code"}), + ), + ( + "session/prompt", + json!({"sessionId": "s1", "prompt": [{"type": "text", "text": "hi"}]}), + ), + ]; + for (method, params) in client_cases { + let parsed = v1::ClientRequest::from_method_and_params(method, Some(params.clone())) + .unwrap_or_else(|e| panic!("{method} failed to parse: {e}")); + assert_eq!( + parsed.method(), + *method, + "{method} routed to the wrong variant", + ); + } + + // Agent-to-client methods. + let agent_cases: &[(&str, serde_json::Value)] = &[ + ( + "session/request_permission", + json!({ + "sessionId": "s1", + "toolCall": {"toolCallId": "t1", "title": "Run tests"}, + "options": [{"optionId": "allow", "name": "Allow", "kind": "allow_once"}], + }), + ), + ( + "fs/read_text_file", + json!({"sessionId": "s1", "path": "/tmp/a"}), + ), + ( + "fs/write_text_file", + json!({"sessionId": "s1", "path": "/tmp/a", "content": "x"}), + ), + ( + "terminal/create", + json!({"sessionId": "s1", "command": "ls"}), + ), + ( + "terminal/output", + json!({"sessionId": "s1", "terminalId": "t1"}), + ), + ( + "terminal/wait_for_exit", + json!({"sessionId": "s1", "terminalId": "t1"}), + ), + ( + "terminal/kill", + json!({"sessionId": "s1", "terminalId": "t1"}), + ), + ( + "terminal/release", + json!({"sessionId": "s1", "terminalId": "t1"}), + ), + ( + "elicitation/create", + json!({"sessionId": "s1", "mode": "form", "message": "pick one", "requestedSchema": {"type": "object", "properties": {}}}), + ), + ]; + for (method, params) in agent_cases { + let parsed = v1::AgentRequest::from_method_and_params(method, Some(params.clone())) + .unwrap_or_else(|e| panic!("{method} failed to parse: {e}")); + assert_eq!( + parsed.method(), + *method, + "{method} routed to the wrong variant", + ); + } +} + +#[test] +fn v1_extension_request_preserves_method_and_raw_params() { + let parsed = v1::ClientRequest::from_method_and_params( + "_com.example/workspace/buffers", + Some(json!({"operation": "list"})), + ) + .unwrap(); + match parsed { + v1::ClientRequest::ExtMethodRequest(ext) => { + assert_eq!(&*ext.method, "_com.example/workspace/buffers"); + let params: serde_json::Value = serde_json::from_str(ext.params.get()).unwrap(); + assert_eq!(params, json!({"operation": "list"})); + } + other => panic!("expected ExtMethodRequest, got {}", other.method()), + } +} + +#[test] +fn v1_unknown_method_is_an_error_not_a_silent_misroute() { + let err = + v1::ClientRequest::from_method_and_params("session/nope", Some(json!({}))).unwrap_err(); + assert!( + matches!(err, MethodDispatchError::UnknownMethod { .. }), + "{err}" + ); +} + +#[test] +fn v1_mismatched_payload_is_an_error_not_a_silent_default() { + // `session/new` requires `cwd`; the derived untagged deserialization used + // to swallow this into an unrelated all-optional variant instead. + let err = + v1::ClientRequest::from_method_and_params("session/new", Some(json!({}))).unwrap_err(); + assert!( + matches!(err, MethodDispatchError::InvalidPayload { .. }), + "{err}" + ); +} + +#[test] +fn v1_notifications_route_and_preserve_payload() { + let parsed = v1::AgentNotification::from_method_and_params( + "session/update", + Some(json!({ + "sessionId": "s1", + "update": {"sessionUpdate": "tool_call_update", "toolCallId": "t1", "status": "in_progress"}, + })), + ) + .unwrap(); + match parsed { + v1::AgentNotification::SessionNotification(notification) => { + assert!(matches!( + notification.update, + v1::SessionUpdate::ToolCallUpdate(_) + )); + } + other => panic!("expected SessionNotification, got {}", other.method()), + } + + let parsed = v1::ClientNotification::from_method_and_params( + "session/cancel", + Some(json!({"sessionId": "s1"})), + ) + .unwrap(); + assert!(matches!( + parsed, + v1::ClientNotification::CancelNotification(_) + )); +} + +#[test] +fn v1_responses_route_by_originating_method() { + let parsed = + v1::AgentResponse::from_method_and_result("session/new", Some(json!({"sessionId": "s1"}))) + .unwrap(); + match parsed { + v1::AgentResponse::NewSessionResponse(response) => { + assert_eq!(&*response.session_id.0, "s1"); + } + other => panic!("expected NewSessionResponse, got {other:?}"), + } + + let parsed = v1::ClientResponse::from_method_and_result( + "fs/read_text_file", + Some(json!({"content": "hello"})), + ) + .unwrap(); + assert!(matches!( + parsed, + v1::ClientResponse::ReadTextFileResponse(_) + )); +} + +#[cfg(feature = "unstable_protocol_v2")] +#[test] +fn v2_requests_notifications_and_responses_route() { + use agent_client_protocol_schema::v2; + + let parsed = v2::ClientRequest::from_method_and_params( + "session/new", + Some(json!({"cwd": "/home/user/project", "mcpServers": []})), + ) + .unwrap(); + assert!(matches!(parsed, v2::ClientRequest::NewSessionRequest(_))); + + let parsed = v2::AgentNotification::from_method_and_params( + "session/update", + Some(json!({ + "sessionId": "s1", + "update": {"sessionUpdate": "tool_call_update", "toolCallId": "t1", "status": "in_progress"}, + })), + ) + .unwrap(); + assert!(matches!( + parsed, + v2::AgentNotification::UpdateSessionNotification(_) + )); + + let parsed = + v2::AgentResponse::from_method_and_result("session/new", Some(json!({"sessionId": "s1"}))) + .unwrap(); + assert!(matches!(parsed, v2::AgentResponse::NewSessionResponse(_))); + + let parsed = + v2::ClientRequest::from_method_and_params("_com.example/thing", Some(json!({"x": 1}))) + .unwrap(); + assert!(matches!(parsed, v2::ClientRequest::ExtMethodRequest(_))); + + let err = v2::ClientRequest::from_method_and_params("session/nope", None).unwrap_err(); + assert!( + matches!(err, MethodDispatchError::UnknownMethod { .. }), + "{err}" + ); +} + +#[test] +fn v1_protocol_level_cancel_request_routes() { + let parsed = v1::ProtocolLevelNotification::from_method_and_params( + "$/cancel_request", + Some(json!({"requestId": 7})), + ) + .unwrap(); + assert!(matches!( + parsed, + v1::ProtocolLevelNotification::CancelRequestNotification(_) + )); + let err = + v1::ProtocolLevelNotification::from_method_and_params("session/new", None).unwrap_err(); + assert!( + matches!(err, MethodDispatchError::UnknownMethod { .. }), + "{err}" + ); +} + +#[test] +fn v1_extension_notification_and_response_preserve_raw_payload() { + let parsed = v1::AgentNotification::from_method_and_params( + "_com.example/progress", + Some(json!({"percent": 42, "stage": "tests"})), + ) + .unwrap(); + match parsed { + v1::AgentNotification::ExtNotification(ext) => { + assert_eq!(&*ext.method, "_com.example/progress"); + let params: serde_json::Value = serde_json::from_str(ext.params.get()).unwrap(); + assert_eq!(params, json!({"percent": 42, "stage": "tests"})); + } + other => panic!("expected ExtNotification, got {}", other.method()), + } + + let parsed = v1::AgentResponse::from_method_and_result( + "_com.example/query", + Some(json!({"answer": [1, 2, 3]})), + ) + .unwrap(); + match parsed { + v1::AgentResponse::ExtMethodResponse(ext) => { + let result: serde_json::Value = serde_json::from_str(ext.0.get()).unwrap(); + assert_eq!(result, json!({"answer": [1, 2, 3]})); + } + other => panic!("expected ExtMethodResponse, got {other:?}"), + } +} + +#[test] +fn v1_null_result_for_ack_style_response_is_accepted() { + // Agents commonly answer ack-style requests with `result: null`; the + // response structs for these methods have all-optional fields. + let parsed = v1::AgentResponse::from_method_and_result("logout", None).unwrap(); + assert!(matches!(parsed, v1::AgentResponse::LogoutResponse(_))); +} + +#[cfg(feature = "unstable_llm_providers")] +#[test] +fn v1_gated_provider_methods_route() { + let parsed = v1::ClientRequest::from_method_and_params("providers/list", None).unwrap(); + assert!(matches!(parsed, v1::ClientRequest::ListProvidersRequest(_))); + // Regression: before method-aware dispatch, providers/list was the + // all-optional variant that silently swallowed session/new payloads. + let parsed = v1::ClientRequest::from_method_and_params( + "session/new", + Some(json!({"cwd": "/tmp", "mcpServers": []})), + ) + .unwrap(); + assert!(matches!(parsed, v1::ClientRequest::NewSessionRequest(_))); +} + +#[cfg(feature = "unstable_session_fork")] +#[test] +fn v1_gated_session_fork_routes() { + let parsed = v1::ClientRequest::from_method_and_params( + "session/fork", + Some(json!({"sessionId": "s1", "cwd": "/tmp", "mcpServers": []})), + ) + .unwrap(); + assert!(matches!(parsed, v1::ClientRequest::ForkSessionRequest(_))); +} + +#[cfg(feature = "unstable_session_archive")] +#[test] +fn v1_gated_session_archive_unarchive_route() { + let parsed = v1::ClientRequest::from_method_and_params( + "session/archive", + Some(json!({"sessionId": "s1"})), + ) + .unwrap(); + assert!(matches!( + parsed, + v1::ClientRequest::ArchiveSessionRequest(_) + )); + let parsed = v1::ClientRequest::from_method_and_params( + "session/unarchive", + Some(json!({"sessionId": "s1"})), + ) + .unwrap(); + assert!(matches!( + parsed, + v1::ClientRequest::UnarchiveSessionRequest(_) + )); +} + +#[cfg(feature = "unstable_session_status")] +#[test] +fn v1_gated_session_status_routes() { + let parsed = v1::ClientRequest::from_method_and_params( + "session/status", + Some(json!({"sessionId": "s1"})), + ) + .unwrap(); + assert!(matches!(parsed, v1::ClientRequest::SessionStatusRequest(_))); +} + +#[cfg(feature = "unstable_session_set_title")] +#[test] +fn v1_gated_session_set_title_routes() { + let parsed = v1::ClientRequest::from_method_and_params( + "session/set_title", + Some(json!({"sessionId": "s1", "title": "Fix the bug"})), + ) + .unwrap(); + assert!(matches!( + parsed, + v1::ClientRequest::SetSessionTitleRequest(_) + )); +} + +#[cfg(feature = "unstable_auth_status")] +#[test] +fn v1_gated_auth_status_routes() { + let parsed = v1::ClientRequest::from_method_and_params("auth/status", None).unwrap(); + assert!(matches!(parsed, v1::ClientRequest::AuthStatusRequest(_))); +} + +#[cfg(feature = "unstable_mcp_over_acp")] +#[test] +fn v1_gated_mcp_message_routes_both_directions() { + let params = json!({ + "serverId": "srv1", + "requestId": "req1", + "method": "tools/list", + "params": null + }); + // `mcp/message` is an agent-to-client request in v1. + let parsed = v1::AgentRequest::from_method_and_params("mcp/message", Some(params)).unwrap(); + assert!(matches!(parsed, v1::AgentRequest::MessageMcpRequest(_))); +} + +#[cfg(feature = "unstable_protocol_v2")] +#[test] +fn v2_gated_and_boxed_variants_route() { + use agent_client_protocol_schema::v2; + + let parsed = v2::ClientRequest::from_method_and_params( + "tool_call/cancel", + Some(json!({"sessionId": "s1", "toolCallId": "t1"})), + ) + .unwrap(); + assert!(matches!( + parsed, + v2::ClientRequest::CancelToolCallRequest(_) + )); + + let parsed = v2::ClientRequest::from_method_and_params( + "terminal/cancel", + Some(json!({"sessionId": "s1", "terminalId": "t1"})), + ) + .unwrap(); + assert!(matches!( + parsed, + v2::ClientRequest::CancelTerminalRequest(_) + )); + + let parsed = v2::ClientNotification::from_method_and_params( + "session/cancel", + Some(json!({"sessionId": "s1"})), + ) + .unwrap(); + assert!(matches!( + parsed, + v2::ClientNotification::CancelSessionNotification(_) + )); + + // Boxed response variants keep their payload. + let parsed = v2::ClientResponse::from_method_and_result( + "session/request_permission", + Some(json!({"outcome": {"outcome": "selected", "optionId": "allow"}})), + ) + .unwrap(); + assert!(matches!( + parsed, + v2::ClientResponse::RequestPermissionResponse(_) + )); +} + +#[cfg(feature = "unstable_protocol_v2")] +#[test] +fn v2_stable_methods_route_to_their_variant() { + use agent_client_protocol_schema::v2; + + // Client-to-agent methods. + let client_cases: &[(&str, serde_json::Value)] = &[ + ( + "initialize", + json!({"protocolVersion": 2, "info": {"name": "c", "version": "1"}}), + ), + ("auth/login", json!({"methodId": "oauth"})), + ("auth/logout", json!({})), + ("session/new", json!({"cwd": "/tmp", "mcpServers": []})), + ("session/list", json!({})), + ("session/delete", json!({"sessionId": "s1"})), + ("session/close", json!({"sessionId": "s1"})), + ("session/resume", json!({"sessionId": "s1", "cwd": "/tmp"})), + ( + "session/set_config_option", + json!({"sessionId": "s1", "configId": "mode", "type": "id", "value": "code"}), + ), + ( + "session/prompt", + json!({"sessionId": "s1", "prompt": [{"type": "text", "text": "hi"}]}), + ), + ]; + for (method, params) in client_cases { + let parsed = v2::ClientRequest::from_method_and_params(method, Some(params.clone())) + .unwrap_or_else(|e| panic!("{method} failed to parse: {e}")); + assert_eq!( + parsed.method(), + *method, + "{method} routed to the wrong variant" + ); + } + + // Agent-to-client methods. + let agent_cases: &[(&str, serde_json::Value)] = &[ + ( + "session/request_permission", + json!({ + "sessionId": "s1", + "title": "Run this script?", + "subject": {"type": "tool_call", "toolCall": {"toolCallId": "t1", "title": "Execute setup script", "kind": "execute", "status": "pending"}}, + "options": [{"optionId": "allow", "name": "Allow", "kind": "allow_once"}], + }), + ), + ( + "elicitation/create", + json!({"sessionId": "s1", "mode": "form", "message": "pick one", "requestedSchema": {"type": "object", "properties": {}}}), + ), + ]; + for (method, params) in agent_cases { + let parsed = v2::AgentRequest::from_method_and_params(method, Some(params.clone())) + .unwrap_or_else(|e| panic!("{method} failed to parse: {e}")); + assert_eq!( + parsed.method(), + *method, + "{method} routed to the wrong variant" + ); + } + + // Notifications. + let parsed = v2::AgentNotification::from_method_and_params( + "session/update", + Some(json!({"sessionId": "s1", "update": {"sessionUpdate": "plan_update", "plan": {"type": "items", "planId": "p1", "entries": []}}})), + ) + .unwrap(); + assert_eq!(parsed.method(), "session/update"); + let parsed = v2::ClientNotification::from_method_and_params( + "session/cancel", + Some(json!({"sessionId": "s1"})), + ) + .unwrap(); + assert_eq!(parsed.method(), "session/cancel"); + + // Responses route by the originating method. + let parsed = v2::AgentResponse::from_method_and_result( + "session/prompt", + Some(json!({"messageId": "m1", "stopReason": "end_turn"})), + ) + .unwrap(); + assert!(matches!(parsed, v2::AgentResponse::PromptResponse(_))); + let parsed = v2::ClientResponse::from_method_and_result( + "elicitation/create", + Some(json!({"action": "cancel"})), + ) + .unwrap(); + assert!(matches!( + parsed, + v2::ClientResponse::CreateElicitationResponse(_) + )); +} diff --git a/schema/v1/schema.json b/schema/v1/schema.json index 776c23150..ee6d5f02c 100644 --- a/schema/v1/schema.json +++ b/schema/v1/schema.json @@ -95,7 +95,7 @@ "description": "Method-specific notification parameters.", "anyOf": [ { - "description": "General protocol-level notifications that all sides are expected to\nimplement.\n\nNotifications whose methods start with '$/' are messages which\nare protocol implementation dependent and might not be implementable in all\nclients or agents. For example if the implementation uses a single threaded\nsynchronous programming language then there is little it can do to react to\na `$/cancel_request` notification. If an agent or client receives\nnotifications starting with '$/' it is free to ignore the notification.\n\nNotifications do not expect a response.", + "description": "General protocol-level notifications that all sides are expected to\nimplement.\n\nNotifications whose methods start with '$/' are messages which\nare protocol implementation dependent and might not be implementable in all\nclients or agents. For example if the implementation uses a single threaded\nsynchronous programming language then there is little it can do to react to\na `$/cancel_request` notification. If an agent or client receives\nnotifications starting with '$/' it is free to ignore the notification.\n\nNotifications do not expect a response.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`ProtocolLevelNotification::from_method_and_params`].", "anyOf": [ { "title": "CancelRequestNotification", @@ -139,7 +139,7 @@ "description": "Method-specific request parameters.", "anyOf": [ { - "description": "All possible requests that an agent can send to a client.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from agent to client.", + "description": "All possible requests that an agent can send to a client.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from agent to client.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`AgentRequest::from_method_and_params`].", "anyOf": [ { "title": "WriteTextFileRequest", @@ -3572,7 +3572,7 @@ "description": "Method-specific notification parameters.", "anyOf": [ { - "description": "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.", + "description": "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`AgentNotification::from_method_and_params`].", "anyOf": [ { "title": "SessionNotification", @@ -4499,7 +4499,7 @@ "description": "Method-specific request parameters.", "anyOf": [ { - "description": "All possible requests that a client can send to an agent.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from client to agent.", + "description": "All possible requests that a client can send to an agent.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from client to agent.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`ClientRequest::from_method_and_params`].", "anyOf": [ { "title": "InitializeRequest", @@ -6007,7 +6007,7 @@ "description": "Method-specific notification parameters.", "anyOf": [ { - "description": "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.", + "description": "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`ClientNotification::from_method_and_params`].", "anyOf": [ { "title": "CancelNotification", diff --git a/schema/v1/schema.unstable.json b/schema/v1/schema.unstable.json index 658800aea..cac6e4696 100644 --- a/schema/v1/schema.unstable.json +++ b/schema/v1/schema.unstable.json @@ -95,7 +95,7 @@ "description": "Method-specific notification parameters.", "anyOf": [ { - "description": "General protocol-level notifications that all sides are expected to\nimplement.\n\nNotifications whose methods start with '$/' are messages which\nare protocol implementation dependent and might not be implementable in all\nclients or agents. For example if the implementation uses a single threaded\nsynchronous programming language then there is little it can do to react to\na `$/cancel_request` notification. If an agent or client receives\nnotifications starting with '$/' it is free to ignore the notification.\n\nNotifications do not expect a response.", + "description": "General protocol-level notifications that all sides are expected to\nimplement.\n\nNotifications whose methods start with '$/' are messages which\nare protocol implementation dependent and might not be implementable in all\nclients or agents. For example if the implementation uses a single threaded\nsynchronous programming language then there is little it can do to react to\na `$/cancel_request` notification. If an agent or client receives\nnotifications starting with '$/' it is free to ignore the notification.\n\nNotifications do not expect a response.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`ProtocolLevelNotification::from_method_and_params`].", "anyOf": [ { "title": "CancelRequestNotification", @@ -177,7 +177,7 @@ "description": "Method-specific request parameters.", "anyOf": [ { - "description": "All possible requests that an agent can send to a client.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from agent to client.", + "description": "All possible requests that an agent can send to a client.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from agent to client.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`AgentRequest::from_method_and_params`].", "anyOf": [ { "title": "WriteTextFileRequest", @@ -4433,7 +4433,7 @@ "description": "Method-specific notification parameters.", "anyOf": [ { - "description": "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.", + "description": "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`AgentNotification::from_method_and_params`].", "anyOf": [ { "title": "SessionNotification", @@ -6241,7 +6241,7 @@ "description": "Method-specific request parameters.", "anyOf": [ { - "description": "All possible requests that a client can send to an agent.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from client to agent.", + "description": "All possible requests that a client can send to an agent.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from client to agent.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`ClientRequest::from_method_and_params`].", "anyOf": [ { "title": "InitializeRequest", @@ -8354,7 +8354,7 @@ "description": "Method-specific notification parameters.", "anyOf": [ { - "description": "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.", + "description": "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`ClientNotification::from_method_and_params`].", "anyOf": [ { "title": "CancelNotification", diff --git a/schema/v2/schema.json b/schema/v2/schema.json index 2643385ad..9981d1ea1 100644 --- a/schema/v2/schema.json +++ b/schema/v2/schema.json @@ -95,7 +95,7 @@ "description": "Method-specific notification parameters.", "anyOf": [ { - "description": "General protocol-level notifications that all sides are expected to\nimplement.\n\nNotifications whose methods start with '$/' are messages which\nare protocol implementation dependent and might not be implementable in all\nclients or agents. For example if the implementation uses a single threaded\nsynchronous programming language then there is little it can do to react to\na `$/cancel_request` notification. If an agent or client receives\nnotifications starting with '$/' it is free to ignore the notification.\n\nNotifications do not expect a response.", + "description": "General protocol-level notifications that all sides are expected to\nimplement.\n\nNotifications whose methods start with '$/' are messages which\nare protocol implementation dependent and might not be implementable in all\nclients or agents. For example if the implementation uses a single threaded\nsynchronous programming language then there is little it can do to react to\na `$/cancel_request` notification. If an agent or client receives\nnotifications starting with '$/' it is free to ignore the notification.\n\nNotifications do not expect a response.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`ProtocolLevelNotification::from_method_and_params`].", "anyOf": [ { "title": "CancelRequestNotification", @@ -139,7 +139,7 @@ "description": "Method-specific request parameters.", "anyOf": [ { - "description": "All possible requests that an agent can send to a client.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from agent to client.", + "description": "All possible requests that an agent can send to a client.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from agent to client.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`AgentRequest::from_method_and_params`].", "anyOf": [ { "title": "RequestPermissionRequest", @@ -4252,7 +4252,7 @@ "description": "Method-specific notification parameters.", "anyOf": [ { - "description": "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.", + "description": "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`AgentNotification::from_method_and_params`].", "anyOf": [ { "title": "UpdateSessionNotification", @@ -6192,7 +6192,7 @@ "description": "Method-specific request parameters.", "anyOf": [ { - "description": "All possible requests that a client can send to an agent.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from client to agent.", + "description": "All possible requests that a client can send to an agent.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from client to agent.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`ClientRequest::from_method_and_params`].", "anyOf": [ { "title": "InitializeRequest", @@ -7521,7 +7521,7 @@ "description": "Method-specific notification parameters.", "anyOf": [ { - "description": "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.", + "description": "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`ClientNotification::from_method_and_params`].", "anyOf": [ { "title": "CancelSessionNotification", diff --git a/schema/v2/schema.unstable.json b/schema/v2/schema.unstable.json index fb1df7475..49822fe58 100644 --- a/schema/v2/schema.unstable.json +++ b/schema/v2/schema.unstable.json @@ -95,7 +95,7 @@ "description": "Method-specific notification parameters.", "anyOf": [ { - "description": "General protocol-level notifications that all sides are expected to\nimplement.\n\nNotifications whose methods start with '$/' are messages which\nare protocol implementation dependent and might not be implementable in all\nclients or agents. For example if the implementation uses a single threaded\nsynchronous programming language then there is little it can do to react to\na `$/cancel_request` notification. If an agent or client receives\nnotifications starting with '$/' it is free to ignore the notification.\n\nNotifications do not expect a response.", + "description": "General protocol-level notifications that all sides are expected to\nimplement.\n\nNotifications whose methods start with '$/' are messages which\nare protocol implementation dependent and might not be implementable in all\nclients or agents. For example if the implementation uses a single threaded\nsynchronous programming language then there is little it can do to react to\na `$/cancel_request` notification. If an agent or client receives\nnotifications starting with '$/' it is free to ignore the notification.\n\nNotifications do not expect a response.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`ProtocolLevelNotification::from_method_and_params`].", "anyOf": [ { "title": "CancelRequestNotification", @@ -177,7 +177,7 @@ "description": "Method-specific request parameters.", "anyOf": [ { - "description": "All possible requests that an agent can send to a client.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from agent to client.", + "description": "All possible requests that an agent can send to a client.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from agent to client.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`AgentRequest::from_method_and_params`].", "anyOf": [ { "title": "RequestPermissionRequest", @@ -5691,7 +5691,7 @@ "description": "Method-specific notification parameters.", "anyOf": [ { - "description": "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.", + "description": "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`AgentNotification::from_method_and_params`].", "anyOf": [ { "title": "UpdateSessionNotification", @@ -7657,7 +7657,7 @@ "description": "Method-specific request parameters.", "anyOf": [ { - "description": "All possible requests that a client can send to an agent.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from client to agent.", + "description": "All possible requests that a client can send to an agent.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from client to agent.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`ClientRequest::from_method_and_params`].", "anyOf": [ { "title": "InitializeRequest", @@ -9547,7 +9547,7 @@ "description": "Method-specific notification parameters.", "anyOf": [ { - "description": "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.", + "description": "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.\n\n# Deserialization\n\nDo not parse incoming messages with the derived untagged `Deserialize`\n(it exists for schema generation): variants are tried in order, so\nall-optional variants silently swallow any payload. Use\n[`ClientNotification::from_method_and_params`].", "anyOf": [ { "title": "CancelSessionNotification",