use serde::{Deserialize, Serialize};
use super::implementation::Implementation;
use super::protocol_version::AcpProtocolVersion;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeRequest {
pub protocol_version: AcpProtocolVersion,
#[serde(default)]
pub client_capabilities: ClientCapabilities,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_info: Option<Implementation>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeResponse {
pub protocol_version: AcpProtocolVersion,
#[serde(default)]
pub agent_capabilities: AgentCapabilities,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_methods: Option<Vec<AuthMethod>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_info: Option<Implementation>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientCapabilities {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fs: Option<FileSystemCapabilities>,
#[serde(default, skip_serializing_if = "is_false")]
pub terminal: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<AuthCapabilities>,
}
#[expect(
clippy::trivially_copy_pass_by_ref,
reason = "serde's skip_serializing_if takes &T regardless of whether T is Copy"
)]
const fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentCapabilities {
#[serde(default, skip_serializing_if = "is_false")]
pub load_session: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_capabilities: Option<PromptCapabilities>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_capabilities: Option<McpCapabilities>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileSystemCapabilities {
#[serde(default)]
pub read_text_file: bool,
#[serde(default)]
pub write_text_file: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthCapabilities {
#[serde(default)]
pub terminal: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PromptCapabilities {
#[serde(default)]
pub image: bool,
#[serde(default)]
pub audio: bool,
#[serde(default)]
pub embedded_context: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpCapabilities {
#[serde(default)]
pub http: bool,
#[serde(default)]
pub sse: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum AuthMethod {
#[serde(rename = "agent")]
Agent(AuthMethodAgent),
#[serde(rename = "terminal")]
Terminal(AuthMethodTerminal),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthMethodAgent {
pub id: String,
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthMethodTerminal {
pub id: String,
pub name: String,
pub description: String,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[derive(Debug)]
#[expect(
dead_code,
reason = "the test only constructs the error variant on failure; the field is read by the Debug derive"
)]
enum Failure {
Json(serde_json::Error),
Other(String),
}
impl From<serde_json::Error> for Failure {
fn from(error: serde_json::Error) -> Self {
Self::Json(error)
}
}
#[test]
fn a_minimal_request_serialises_to_the_wire_form() -> Result<(), Failure> {
let request = InitializeRequest {
protocol_version: AcpProtocolVersion::CURRENT,
client_capabilities: ClientCapabilities::default(),
client_info: None,
};
let value = serde_json::to_value(&request)?;
let protocol_version = value
.get("protocolVersion")
.and_then(serde_json::Value::as_u64);
let client_capabilities = value.get("clientCapabilities");
assert_eq!(protocol_version, Some(1));
assert_eq!(client_capabilities, Some(&json!({})));
assert!(value.get("clientInfo").is_none(), "None omits the field");
Ok(())
}
#[test]
fn a_full_request_round_trips() -> Result<(), Failure> {
let request = InitializeRequest {
protocol_version: AcpProtocolVersion::CURRENT,
client_capabilities: ClientCapabilities {
fs: Some(FileSystemCapabilities {
read_text_file: true,
write_text_file: true,
}),
terminal: true,
auth: Some(AuthCapabilities { terminal: true }),
},
client_info: Some(Implementation::protocol_droid("test", "0.1.0")),
};
let json = serde_json::to_string(&request)?;
let round_tripped: InitializeRequest = serde_json::from_str(&json)?;
assert_eq!(round_tripped, request);
Ok(())
}
#[test]
fn auth_method_discriminator_is_the_type_field() -> Result<(), Failure> {
let agent = AuthMethod::Agent(AuthMethodAgent {
id: "test".to_owned(),
name: "Test".to_owned(),
description: "An auth method".to_owned(),
});
let terminal = AuthMethod::Terminal(AuthMethodTerminal {
id: "dev-terminal".to_owned(),
name: "Terminal".to_owned(),
description: "A terminal auth method".to_owned(),
});
let agent_value = serde_json::to_value(&agent)?;
let terminal_value = serde_json::to_value(&terminal)?;
let agent_type = agent_value.get("type").and_then(serde_json::Value::as_str);
let terminal_type = terminal_value
.get("type")
.and_then(serde_json::Value::as_str);
assert_eq!(agent_type, Some("agent"));
assert_eq!(terminal_type, Some("terminal"));
Ok(())
}
#[test]
fn a_minimal_response_round_trips() -> Result<(), Failure> {
let response = InitializeResponse {
protocol_version: AcpProtocolVersion::CURRENT,
agent_capabilities: AgentCapabilities {
load_session: true,
prompt_capabilities: Some(PromptCapabilities {
image: true,
audio: false,
embedded_context: true,
}),
mcp_capabilities: Some(McpCapabilities {
http: true,
sse: false,
}),
},
auth_methods: None,
agent_info: Some(Implementation::protocol_droid("test-agent", "0.1.0")),
};
let json = serde_json::to_string(&response)?;
let round_tripped: InitializeResponse = serde_json::from_str(&json)?;
assert_eq!(round_tripped, response);
Ok(())
}
#[test]
fn a_response_with_auth_methods_carries_them_on_the_wire() -> Result<(), Failure> {
let response = InitializeResponse {
protocol_version: AcpProtocolVersion::CURRENT,
agent_capabilities: AgentCapabilities::default(),
auth_methods: Some(vec![AuthMethod::Terminal(AuthMethodTerminal {
id: "dev-terminal".to_owned(),
name: "Terminal".to_owned(),
description: "Sign in via the terminal".to_owned(),
})]),
agent_info: None,
};
let value = serde_json::to_value(&response)?;
let methods = value
.get("authMethods")
.and_then(|v| v.as_array())
.ok_or_else(|| Failure::Other("authMethods is not an array".to_owned()))?;
let first = methods
.first()
.ok_or_else(|| Failure::Other("authMethods is empty".to_owned()))?;
let first_type = first.get("type").and_then(serde_json::Value::as_str);
assert_eq!(first_type, Some("terminal"));
Ok(())
}
}