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}