Skip to main content

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}