protocol_droid/domain/acp/method.rs
1//! `AcpMethod` — every method the ACP v1.25.0 schema names.
2//!
3//! # Why this is a flat enum, not a `MethodTable` trait
4//!
5//! The protocol-neutral core (MCP and A2A) lives in
6//! [`crate::domain::jsonrpc`]. The method table is the
7//! per-protocol translation layer between the wire and the
8//! application handler. A flat enum over a `MethodTable` trait
9//! is `match`-friendly for the dispatcher, `serde`-friendly for
10//! the wire, and the same shape every per-protocol module
11//! uses. Mirroring that shape here keeps the dispatcher
12//! generic.
13//!
14//! # Why the wire names are slash-separated
15//!
16//! ACP's `meta.json` (vendored at
17//! `crates/protocol-droid-schema/spec/acp/v1.25.0/meta.json`)
18//! names methods as `session/prompt`, `session/cancel`, and so
19//! on. The slash is part of the wire name, not a separator
20//! this crate invented; a peer that receives a method without
21//! the slash is a wire violation. The `as_wire_name` and
22//! `FromStr` impls keep the slash visible at every boundary.
23
24use core::fmt;
25use core::str::FromStr;
26
27use serde::{Deserialize, Serialize};
28
29/// A method on the ACP wire. Carries the direction (agent-side
30/// or client-side) and the slash-separated wire name.
31///
32/// The wire form is the JSON-RPC 2.0 `method` field, not a
33/// path. The slash is part of the name.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case", tag = "direction", content = "name")]
36pub enum AcpMethod {
37 /// A method the editor (client) calls on the agent.
38 /// An agent that supports a method answers the call.
39 /// The wire name is the slash-separated form from
40 /// `meta.json::agentMethods`.
41 Agent(AgentMethod),
42 /// A method the agent calls on the editor (client).
43 /// An editor that supports a method answers the call.
44 /// The wire name is the slash-separated form from
45 /// `meta.json::clientMethods`.
46 Client(ClientMethod),
47 /// A JSON-RPC standard method, prefixed `$/`. Only
48 /// `$/cancel_request` is named in the v1 schema.
49 Protocol(ProtocolMethod),
50}
51
52/// The 13 methods an ACP agent publishes, as named in
53/// `meta.json::agentMethods`. The wire names are the
54/// slash-separated form.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum AgentMethod {
58 /// `initialize` — the editor's first call, advertising
59 /// its protocol version and capabilities. The agent
60 /// answers with its own version and capabilities.
61 Initialize,
62 /// `authenticate` — proves the editor's identity after
63 /// `initialize` has negotiated an auth method.
64 Authenticate,
65 /// `session/new` — opens a new session.
66 SessionNew,
67 /// `session/load` — resumes a session from a checkpoint.
68 SessionLoad,
69 /// `session/set_mode` — switches the session's mode
70 /// (e.g. ask vs. plan).
71 SessionSetMode,
72 /// `session/set_config_option` — sets a per-session
73 /// configuration value the agent advertised in
74 /// `initialize`.
75 SessionSetConfigOption,
76 /// `session/prompt` — sends a user turn. The agent
77 /// streams `session/update` notifications back through
78 /// the editor.
79 SessionPrompt,
80 /// `session/cancel` — cancels an in-flight `session/prompt`.
81 SessionCancel,
82 /// `session/list` — enumerates sessions.
83 SessionList,
84 /// `session/delete` — removes a session.
85 SessionDelete,
86 /// `session/resume` — resumes a previously-closed
87 /// session.
88 SessionResume,
89 /// `session/close` — closes a session without deleting
90 /// it.
91 SessionClose,
92 /// `logout` — terminates the auth session.
93 Logout,
94}
95
96/// The 11 methods an ACP editor (client) publishes.
97///
98/// The names are the slash-separated form
99/// `meta.json::clientMethods` lists. The agent
100/// calls these during a session to ask the editor
101/// for input, render updates, drive the editor's
102/// filesystem, drive the editor's terminal, or run
103/// an elicitation flow.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum ClientMethod {
107 /// `session/request_permission` — the agent asks the
108 /// editor's user whether a tool call may proceed.
109 SessionRequestPermission,
110 /// `session/update` — the agent streams a turn update
111 /// (a chunk of text, a tool call, a status change) to
112 /// the editor.
113 SessionUpdate,
114 /// `fs/write_text_file` — the agent asks the editor
115 /// to write a file. The editor decides whether to
116 /// prompt the user, write silently, or refuse.
117 FsWriteTextFile,
118 /// `fs/read_text_file` — the agent asks the editor
119 /// to read a file. The editor decides what to return
120 /// (a `fs/read_text_file` request may hit an
121 /// editor-side virtual file system, not the disk).
122 FsReadTextFile,
123 /// `terminal/create` — the agent asks the editor to
124 /// allocate a terminal. The editor returns an
125 /// identifier the agent uses for follow-up calls.
126 TerminalCreate,
127 /// `terminal/output` — the agent reads captured
128 /// terminal output.
129 TerminalOutput,
130 /// `terminal/release` — the agent releases a terminal
131 /// it no longer needs.
132 TerminalRelease,
133 /// `terminal/wait_for_exit` — the agent waits for a
134 /// terminal command to exit.
135 TerminalWaitForExit,
136 /// `terminal/kill` — the agent sends `SIGTERM` to a
137 /// running terminal command.
138 TerminalKill,
139 /// `elicitation/create` — the agent starts a
140 /// structured elicitation (e.g. "pick one of these
141 /// three options") in the editor.
142 ElicitationCreate,
143 /// `elicitation/complete` — the agent signals the
144 /// elicitation is done; the editor's user has either
145 /// answered or dismissed.
146 ElicitationComplete,
147}
148
149/// A JSON-RPC standard method, prefixed `$/`. The v1 schema
150/// names only `$/cancel_request`.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum ProtocolMethod {
154 /// `$/cancel_request` — a peer cancels an in-flight
155 /// request by id. The peer that sent the request
156 /// discards it (or returns a "cancelled" error if the
157 /// response already left when the cancel arrived).
158 CancelRequest,
159}
160
161impl AgentMethod {
162 /// The wire name as it appears in a JSON-RPC envelope.
163 /// The slash is part of the name; the peer is
164 /// required to send the slashes too.
165 pub const fn as_wire_name(self) -> &'static str {
166 match self {
167 Self::Initialize => "initialize",
168 Self::Authenticate => "authenticate",
169 Self::SessionNew => "session/new",
170 Self::SessionLoad => "session/load",
171 Self::SessionSetMode => "session/set_mode",
172 Self::SessionSetConfigOption => "session/set_config_option",
173 Self::SessionPrompt => "session/prompt",
174 Self::SessionCancel => "session/cancel",
175 Self::SessionList => "session/list",
176 Self::SessionDelete => "session/delete",
177 Self::SessionResume => "session/resume",
178 Self::SessionClose => "session/close",
179 Self::Logout => "logout",
180 }
181 }
182}
183
184impl ClientMethod {
185 /// The wire name as it appears in a JSON-RPC envelope.
186 pub const fn as_wire_name(self) -> &'static str {
187 match self {
188 Self::SessionRequestPermission => "session/request_permission",
189 Self::SessionUpdate => "session/update",
190 Self::FsWriteTextFile => "fs/write_text_file",
191 Self::FsReadTextFile => "fs/read_text_file",
192 Self::TerminalCreate => "terminal/create",
193 Self::TerminalOutput => "terminal/output",
194 Self::TerminalRelease => "terminal/release",
195 Self::TerminalWaitForExit => "terminal/wait_for_exit",
196 Self::TerminalKill => "terminal/kill",
197 Self::ElicitationCreate => "elicitation/create",
198 Self::ElicitationComplete => "elicitation/complete",
199 }
200 }
201}
202
203impl ProtocolMethod {
204 /// The wire name, including the `$/` prefix.
205 pub const fn as_wire_name(self) -> &'static str {
206 match self {
207 Self::CancelRequest => "$/cancel_request",
208 }
209 }
210}
211
212impl AcpMethod {
213 /// The wire name as it appears in a JSON-RPC envelope.
214 /// The direction is implied by the variant, not part
215 /// of the wire name.
216 pub const fn as_wire_name(self) -> &'static str {
217 match self {
218 Self::Agent(method) => method.as_wire_name(),
219 Self::Client(method) => method.as_wire_name(),
220 Self::Protocol(method) => method.as_wire_name(),
221 }
222 }
223}
224
225impl fmt::Display for AcpMethod {
226 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
227 formatter.write_str(self.as_wire_name())
228 }
229}
230
231/// The error returned by [`AcpMethod::from_str`].
232///
233/// The `meta.json`-listed set is exhaustive for v1.25.0,
234/// so every well-formed wire name parses; an unknown
235/// name is a wire violation or a future method.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct AcpMethodParseError {
238 input: String,
239}
240
241impl fmt::Display for AcpMethodParseError {
242 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
243 write!(
244 formatter,
245 "ACP method {:?} is not in the v1.25.0 method table",
246 self.input
247 )
248 }
249}
250
251impl std::error::Error for AcpMethodParseError {}
252
253impl FromStr for AcpMethod {
254 type Err = AcpMethodParseError;
255
256 fn from_str(input: &str) -> Result<Self, Self::Err> {
257 if let Some(rest) = input.strip_prefix("$/") {
258 return match rest {
259 "cancel_request" => Ok(Self::Protocol(ProtocolMethod::CancelRequest)),
260 _ => Err(AcpMethodParseError {
261 input: input.to_owned(),
262 }),
263 };
264 }
265 for method in [
266 Self::Agent(AgentMethod::Initialize),
267 Self::Agent(AgentMethod::Authenticate),
268 Self::Agent(AgentMethod::SessionNew),
269 Self::Agent(AgentMethod::SessionLoad),
270 Self::Agent(AgentMethod::SessionSetMode),
271 Self::Agent(AgentMethod::SessionSetConfigOption),
272 Self::Agent(AgentMethod::SessionPrompt),
273 Self::Agent(AgentMethod::SessionCancel),
274 Self::Agent(AgentMethod::SessionList),
275 Self::Agent(AgentMethod::SessionDelete),
276 Self::Agent(AgentMethod::SessionResume),
277 Self::Agent(AgentMethod::SessionClose),
278 Self::Agent(AgentMethod::Logout),
279 ] {
280 if method.as_wire_name() == input {
281 return Ok(method);
282 }
283 }
284 for method in [
285 Self::Client(ClientMethod::SessionRequestPermission),
286 Self::Client(ClientMethod::SessionUpdate),
287 Self::Client(ClientMethod::FsWriteTextFile),
288 Self::Client(ClientMethod::FsReadTextFile),
289 Self::Client(ClientMethod::TerminalCreate),
290 Self::Client(ClientMethod::TerminalOutput),
291 Self::Client(ClientMethod::TerminalRelease),
292 Self::Client(ClientMethod::TerminalWaitForExit),
293 Self::Client(ClientMethod::TerminalKill),
294 Self::Client(ClientMethod::ElicitationCreate),
295 Self::Client(ClientMethod::ElicitationComplete),
296 ] {
297 if method.as_wire_name() == input {
298 return Ok(method);
299 }
300 }
301 Err(AcpMethodParseError {
302 input: input.to_owned(),
303 })
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 /// Every method named in `meta.json` round-trips
312 /// through `as_wire_name` and `FromStr`. The wire
313 /// names are the ones the spec publishes; the
314 /// dispatcher matches on the typed enum, not on
315 /// the string, so a typo in the wire name would
316 /// only show up here.
317 #[test]
318 fn every_meta_method_round_trips_through_wire_name() -> Result<(), AcpMethodParseError> {
319 let cases: &[(AcpMethod, &str)] = &[
320 (AcpMethod::Agent(AgentMethod::Initialize), "initialize"),
321 (AcpMethod::Agent(AgentMethod::Authenticate), "authenticate"),
322 (AcpMethod::Agent(AgentMethod::SessionNew), "session/new"),
323 (AcpMethod::Agent(AgentMethod::SessionLoad), "session/load"),
324 (
325 AcpMethod::Agent(AgentMethod::SessionSetMode),
326 "session/set_mode",
327 ),
328 (
329 AcpMethod::Agent(AgentMethod::SessionSetConfigOption),
330 "session/set_config_option",
331 ),
332 (
333 AcpMethod::Agent(AgentMethod::SessionPrompt),
334 "session/prompt",
335 ),
336 (
337 AcpMethod::Agent(AgentMethod::SessionCancel),
338 "session/cancel",
339 ),
340 (AcpMethod::Agent(AgentMethod::SessionList), "session/list"),
341 (
342 AcpMethod::Agent(AgentMethod::SessionDelete),
343 "session/delete",
344 ),
345 (
346 AcpMethod::Agent(AgentMethod::SessionResume),
347 "session/resume",
348 ),
349 (AcpMethod::Agent(AgentMethod::SessionClose), "session/close"),
350 (AcpMethod::Agent(AgentMethod::Logout), "logout"),
351 (
352 AcpMethod::Client(ClientMethod::SessionRequestPermission),
353 "session/request_permission",
354 ),
355 (
356 AcpMethod::Client(ClientMethod::SessionUpdate),
357 "session/update",
358 ),
359 (
360 AcpMethod::Client(ClientMethod::FsWriteTextFile),
361 "fs/write_text_file",
362 ),
363 (
364 AcpMethod::Client(ClientMethod::FsReadTextFile),
365 "fs/read_text_file",
366 ),
367 (
368 AcpMethod::Client(ClientMethod::TerminalCreate),
369 "terminal/create",
370 ),
371 (
372 AcpMethod::Client(ClientMethod::TerminalOutput),
373 "terminal/output",
374 ),
375 (
376 AcpMethod::Client(ClientMethod::TerminalRelease),
377 "terminal/release",
378 ),
379 (
380 AcpMethod::Client(ClientMethod::TerminalWaitForExit),
381 "terminal/wait_for_exit",
382 ),
383 (
384 AcpMethod::Client(ClientMethod::TerminalKill),
385 "terminal/kill",
386 ),
387 (
388 AcpMethod::Client(ClientMethod::ElicitationCreate),
389 "elicitation/create",
390 ),
391 (
392 AcpMethod::Client(ClientMethod::ElicitationComplete),
393 "elicitation/complete",
394 ),
395 (
396 AcpMethod::Protocol(ProtocolMethod::CancelRequest),
397 "$/cancel_request",
398 ),
399 ];
400 assert_eq!(
401 cases.len(),
402 25,
403 "every published ACP v1 method should appear"
404 );
405 for (method, expected_name) in cases {
406 assert_eq!(method.as_wire_name(), *expected_name);
407 let round_tripped: AcpMethod = expected_name.parse()?;
408 assert_eq!(
409 round_tripped, *method,
410 "round-trip failed for {expected_name}"
411 );
412 }
413 Ok(())
414 }
415
416 /// An unknown wire name is an error, not a silent
417 /// fallback. A future method would either extend
418 /// the enum or land in `Unknown { name }` if we
419 /// ever add that variant; today, an unknown name
420 /// is a wire violation.
421 #[test]
422 fn an_unknown_wire_name_is_an_error() {
423 let Err(error) = "session/unicorn".parse::<AcpMethod>() else {
424 return;
425 };
426 assert_eq!(error.input, "session/unicorn");
427 }
428}