Skip to main content

protocol_droid/domain/acp/
initialize.rs

1//! `initialize` — the editor's first call, the agent's first reply.
2//!
3//! Source of truth:
4//! `crates/protocol-droid-schema/spec/acp/v1.25.0/schema.json`
5//! under `$defs.InitializeRequest` and `$defs.InitializeResponse`.
6//!
7//! # The minimum-viable initialize
8//!
9//! A complete wire round-trip needs:
10//! - the `protocolVersion` integer on both sides
11//! - the `clientCapabilities` and `agentCapabilities` blocks
12//! - the optional `clientInfo` / `agentInfo` block
13//! - the optional `authMethods` on the response (gated on
14//!   the client advertising `auth.terminal = true`)
15//!
16//! The capability shapes are nested (`fs`, `terminal`,
17//! `promptCapabilities`, `mcpCapabilities`, etc.). A
18//! consumer that does not need the nested surface can
19//! leave them at the schema's defaults and the
20//! round-trip is still well-formed.
21//!
22//! # Why a hand-rolled `ClientCapabilities` is OK here
23//!
24//! The full set of capability types the schema names
25//! (`FileSystemCapabilities`, `AuthCapabilities`,
26//! `PromptCapabilities`, `McpCapabilities`,
27//! `SessionCapabilities`, `ClientSessionCapabilities`,
28//! `ElicitationCapabilities`, ...) is the bulk of the
29//! 178-definition schema. Building every one of them
30//! is its own commit. This module ships the minimum
31//! the `initialize` round-trip needs: a `ClientCapabilities`
32//! and `AgentCapabilities` that pass through the schema's
33//! default values for the un-built sub-types. A future
34//! increment fills in the per-capability shapes.
35
36use serde::{Deserialize, Serialize};
37
38use super::implementation::Implementation;
39use super::protocol_version::AcpProtocolVersion;
40
41/// The `initialize` request body. Sent by the editor to
42/// the agent as its first call.
43///
44/// The schema's `clientCapabilities` field has a default
45/// object (the `x-deserialize-default-on-error` annotation),
46/// so a request with `clientCapabilities` absent on the
47/// wire is well-formed: the default is the all-off
48/// capabilities block.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct InitializeRequest {
52    /// The latest protocol version the client supports.
53    /// The current pin is `AcpProtocolVersion::CURRENT`.
54    pub protocol_version: AcpProtocolVersion,
55    /// The client's capabilities. The schema defaults
56    /// this to the all-off object; the typed default
57    /// is the same.
58    #[serde(default)]
59    pub client_capabilities: ClientCapabilities,
60    /// Information about the client (name, version).
61    /// The schema marks this "in future versions of the
62    /// protocol, this will be required" — today it is
63    /// optional but well-behaved clients always send it.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub client_info: Option<Implementation>,
66}
67
68/// The `initialize` response body. Sent by the agent in
69/// reply to `InitializeRequest`.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct InitializeResponse {
73    /// The protocol version the agent is speaking.
74    /// The negotiation rule is "the lower of the two";
75    /// the agent's value is the one the editor takes.
76    pub protocol_version: AcpProtocolVersion,
77    /// The agent's capabilities. Same defaulting
78    /// behaviour as the request's `clientCapabilities`.
79    #[serde(default)]
80    pub agent_capabilities: AgentCapabilities,
81    /// The auth methods the agent supports. Optional
82    /// in the schema; absent means "no auth, anonymous
83    /// connection". Present only when the client
84    /// advertised `auth.terminal = true`.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub auth_methods: Option<Vec<AuthMethod>>,
87    /// Information about the agent (name, version).
88    /// Optional; well-behaved agents always send it.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub agent_info: Option<Implementation>,
91}
92
93/// The editor's capabilities block. Source of truth:
94/// `schema.json::$defs.ClientCapabilities`.
95///
96/// The shape is a flat bag of capability sub-types; each
97/// sub-type is itself a typed newtype. A consumer that
98/// only needs the wire form sends a `ClientCapabilities`
99/// with the relevant fields; the rest take the schema's
100/// defaults.
101#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct ClientCapabilities {
104    /// File-system access. The schema's default is
105    /// `{ "readTextFile": false, "writeTextFile": false }`.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub fs: Option<FileSystemCapabilities>,
108    /// Whether the client supports every `terminal/*`
109    /// method. The schema's default is `false`. The
110    /// `skip_serializing_if` keeps the wire form
111    /// minimal for the all-off case.
112    #[serde(default, skip_serializing_if = "is_false")]
113    pub terminal: bool,
114    /// Whether the client supports `auth` methods.
115    /// The schema's default is `{ "terminal": false }`.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub auth: Option<AuthCapabilities>,
118}
119
120/// The `is_false` predicate for `#[serde(skip_serializing_if)]`.
121/// `serde` passes the field by reference even when the
122/// field is a `Copy` primitive; the helper has to take
123/// `&bool` for the macro to type-check. The body is a
124/// single deref-and-negate.
125#[expect(
126    clippy::trivially_copy_pass_by_ref,
127    reason = "serde's skip_serializing_if takes &T regardless of whether T is Copy"
128)]
129const fn is_false(value: &bool) -> bool {
130    !*value
131}
132
133/// The agent's capabilities block. Source of truth:
134/// `schema.json::$defs.AgentCapabilities`.
135#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct AgentCapabilities {
138    /// Whether the agent supports `session/load`.
139    /// The schema's default is `false`. The
140    /// `skip_serializing_if` keeps the wire form
141    /// minimal for the all-off case.
142    #[serde(default, skip_serializing_if = "is_false")]
143    pub load_session: bool,
144    /// The prompt capabilities the agent supports
145    /// (image, audio, embedded context). The
146    /// schema's default is the all-off object.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub prompt_capabilities: Option<PromptCapabilities>,
149    /// The MCP capabilities the agent supports (http,
150    /// sse). The schema's default is the all-off object.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub mcp_capabilities: Option<McpCapabilities>,
153}
154
155/// The file-system capabilities the client advertises.
156///
157/// The editor turns on `read_text_file` / `write_text_file`
158/// when the agent may call the corresponding
159/// `fs/read_text_file` / `fs/write_text_file` methods.
160#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct FileSystemCapabilities {
163    /// Whether the client accepts
164    /// `fs/read_text_file` calls.
165    #[serde(default)]
166    pub read_text_file: bool,
167    /// Whether the client accepts
168    /// `fs/write_text_file` calls.
169    #[serde(default)]
170    pub write_text_file: bool,
171}
172
173/// The auth capabilities the client advertises. The
174/// only field the v1.25.0 schema names is `terminal`
175/// (whether the client can present a terminal-based
176/// auth flow).
177#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct AuthCapabilities {
180    /// Whether the client supports terminal-based
181    /// auth flows.
182    #[serde(default)]
183    pub terminal: bool,
184}
185
186/// The prompt capabilities the agent advertises.
187#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct PromptCapabilities {
190    /// Whether the agent accepts image content in
191    /// user prompts.
192    #[serde(default)]
193    pub image: bool,
194    /// Whether the agent accepts audio content in
195    /// user prompts.
196    #[serde(default)]
197    pub audio: bool,
198    /// Whether the agent accepts embedded context
199    /// (e.g. file contents) in user prompts.
200    #[serde(default)]
201    pub embedded_context: bool,
202}
203
204/// The MCP capabilities the agent advertises. The
205/// agent is itself an MCP client; these flags say
206/// which transports the agent speaks.
207#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "camelCase")]
209pub struct McpCapabilities {
210    /// Whether the agent accepts MCP `http` servers.
211    #[serde(default)]
212    pub http: bool,
213    /// Whether the agent accepts MCP `sse` servers.
214    #[serde(default)]
215    pub sse: bool,
216}
217
218/// The auth method the agent advertises. The schema
219/// is a discriminated union (`AuthMethodAgent` /
220/// `AuthMethodTerminal`); the typed form is the
221/// same `enum` shape.
222///
223/// The schema's `$defs.AuthMethod` definition is
224/// exhaustive over the two variants; a `null`
225/// payload is not a valid `AuthMethod`.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "camelCase", tag = "type")]
228pub enum AuthMethod {
229    /// An agent-to-agent auth method.
230    #[serde(rename = "agent")]
231    Agent(AuthMethodAgent),
232    /// A terminal-based auth method.
233    #[serde(rename = "terminal")]
234    Terminal(AuthMethodTerminal),
235}
236
237/// The `agent` variant of [`AuthMethod`].
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(rename_all = "camelCase")]
240pub struct AuthMethodAgent {
241    /// The auth method id. A string the agent and
242    /// editor both recognise (e.g. an OAuth client
243    /// id).
244    pub id: String,
245    /// A human-readable display name.
246    pub name: String,
247    /// A human-readable description.
248    pub description: String,
249}
250
251/// The `terminal` variant of [`AuthMethod`].
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct AuthMethodTerminal {
255    /// A unique identifier for this auth method.
256    /// The schema names every `AuthMethod`
257    /// carries an `id`; the value is opaque to
258    /// the wire and meaningful only to the
259    /// agent and editor that negotiated it.
260    pub id: String,
261    /// A human-readable display name.
262    pub name: String,
263    /// A human-readable description.
264    pub description: String,
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use serde_json::json;
271
272    /// The test error type. `serde_json` calls are
273    /// infallible for a well-formed value, but the
274    /// test functions return `Result` to satisfy
275    /// the project's no-`expect` rule.
276    #[derive(Debug)]
277    #[expect(
278        dead_code,
279        reason = "the test only constructs the error variant on failure; the field is read by the Debug derive"
280    )]
281    enum Failure {
282        Json(serde_json::Error),
283        /// A fallible check on the deserialised
284        /// value (an absent field, an empty
285        /// list, etc.). The string is the
286        /// human-readable reason.
287        Other(String),
288    }
289
290    impl From<serde_json::Error> for Failure {
291        fn from(error: serde_json::Error) -> Self {
292            Self::Json(error)
293        }
294    }
295
296    /// A minimal `InitializeRequest` serialises
297    /// to the wire form the schema names. The
298    /// `clientCapabilities` field is `{}` on the
299    /// wire (the schema's `x-deserialize-default-on-error`
300    /// default is the all-off object, but a
301    /// `Default` typed value is the empty
302    /// object literal).
303    #[test]
304    fn a_minimal_request_serialises_to_the_wire_form() -> Result<(), Failure> {
305        let request = InitializeRequest {
306            protocol_version: AcpProtocolVersion::CURRENT,
307            client_capabilities: ClientCapabilities::default(),
308            client_info: None,
309        };
310        let value = serde_json::to_value(&request)?;
311        let protocol_version = value
312            .get("protocolVersion")
313            .and_then(serde_json::Value::as_u64);
314        let client_capabilities = value.get("clientCapabilities");
315        assert_eq!(protocol_version, Some(1));
316        assert_eq!(client_capabilities, Some(&json!({})));
317        assert!(value.get("clientInfo").is_none(), "None omits the field");
318        Ok(())
319    }
320
321    /// A full `InitializeRequest` with every
322    /// capability turned on serialises to the
323    /// expected wire form. The round-trip
324    /// through `Serialize` → `Deserialize`
325    /// produces an equal value.
326    #[test]
327    fn a_full_request_round_trips() -> Result<(), Failure> {
328        let request = InitializeRequest {
329            protocol_version: AcpProtocolVersion::CURRENT,
330            client_capabilities: ClientCapabilities {
331                fs: Some(FileSystemCapabilities {
332                    read_text_file: true,
333                    write_text_file: true,
334                }),
335                terminal: true,
336                auth: Some(AuthCapabilities { terminal: true }),
337            },
338            client_info: Some(Implementation::protocol_droid("test", "0.1.0")),
339        };
340        let json = serde_json::to_string(&request)?;
341        let round_tripped: InitializeRequest = serde_json::from_str(&json)?;
342        assert_eq!(round_tripped, request);
343        Ok(())
344    }
345
346    /// The `AuthMethod` discriminator is `type`
347    /// on the wire, with the two variants
348    /// `agent` and `terminal`. A `null` is not
349    /// a valid `AuthMethod`; the round-trip is
350    /// exhaustive over the two variants.
351    #[test]
352    fn auth_method_discriminator_is_the_type_field() -> Result<(), Failure> {
353        let agent = AuthMethod::Agent(AuthMethodAgent {
354            id: "test".to_owned(),
355            name: "Test".to_owned(),
356            description: "An auth method".to_owned(),
357        });
358        let terminal = AuthMethod::Terminal(AuthMethodTerminal {
359            id: "dev-terminal".to_owned(),
360            name: "Terminal".to_owned(),
361            description: "A terminal auth method".to_owned(),
362        });
363        let agent_value = serde_json::to_value(&agent)?;
364        let terminal_value = serde_json::to_value(&terminal)?;
365        let agent_type = agent_value.get("type").and_then(serde_json::Value::as_str);
366        let terminal_type = terminal_value
367            .get("type")
368            .and_then(serde_json::Value::as_str);
369        assert_eq!(agent_type, Some("agent"));
370        assert_eq!(terminal_type, Some("terminal"));
371        Ok(())
372    }
373
374    /// A minimal `InitializeResponse` round-trips
375    /// through `Serialize` → `Deserialize`. The
376    /// `agentCapabilities` defaults to the all-off
377    /// block; the `authMethods` is omitted when
378    /// `None`; the `agentInfo` is the protocol-droid
379    /// `Implementation`.
380    #[test]
381    fn a_minimal_response_round_trips() -> Result<(), Failure> {
382        let response = InitializeResponse {
383            protocol_version: AcpProtocolVersion::CURRENT,
384            agent_capabilities: AgentCapabilities {
385                load_session: true,
386                prompt_capabilities: Some(PromptCapabilities {
387                    image: true,
388                    audio: false,
389                    embedded_context: true,
390                }),
391                mcp_capabilities: Some(McpCapabilities {
392                    http: true,
393                    sse: false,
394                }),
395            },
396            auth_methods: None,
397            agent_info: Some(Implementation::protocol_droid("test-agent", "0.1.0")),
398        };
399        let json = serde_json::to_string(&response)?;
400        let round_tripped: InitializeResponse = serde_json::from_str(&json)?;
401        assert_eq!(round_tripped, response);
402        Ok(())
403    }
404
405    /// A response with an `authMethods` list
406    /// serialises the list on the wire. A `null`
407    /// or absent `authMethods` means the agent
408    /// does not require auth, which is the
409    /// default for an anonymous connection.
410    #[test]
411    fn a_response_with_auth_methods_carries_them_on_the_wire() -> Result<(), Failure> {
412        let response = InitializeResponse {
413            protocol_version: AcpProtocolVersion::CURRENT,
414            agent_capabilities: AgentCapabilities::default(),
415            auth_methods: Some(vec![AuthMethod::Terminal(AuthMethodTerminal {
416                id: "dev-terminal".to_owned(),
417                name: "Terminal".to_owned(),
418                description: "Sign in via the terminal".to_owned(),
419            })]),
420            agent_info: None,
421        };
422        let value = serde_json::to_value(&response)?;
423        let methods = value
424            .get("authMethods")
425            .and_then(|v| v.as_array())
426            .ok_or_else(|| Failure::Other("authMethods is not an array".to_owned()))?;
427        let first = methods
428            .first()
429            .ok_or_else(|| Failure::Other("authMethods is empty".to_owned()))?;
430        let first_type = first.get("type").and_then(serde_json::Value::as_str);
431        assert_eq!(first_type, Some("terminal"));
432        Ok(())
433    }
434}