Skip to main content

protocol_droid/domain/acp/
feature.rs

1//! `AcpFeatureProfile` — the ACP capability bitset, derived from
2//! the negotiated version × agent capabilities × client
3//! capabilities × transport.
4//!
5//! # Why a separate profile type
6//!
7//! The MCP and A2A modules each have their own `FeatureProfile`.
8//! ACP's profile is a third one because the features it
9//! enumerates are independent: a peer that speaks ACP does
10//! not speak MCP, and the methods it advertises are the
11//! slash-separated form `meta.json` lists. The cross-protocol
12//! bridge holds the *union* of the three profiles side-by-side;
13//! it does not collapse them into one bitset.
14//!
15//! # What this is
16//!
17//! The profile is a `[bool; N_FEATURES]` indexed by
18//! [`AcpFeature`]. The shape mirrors the A2A `A2aFeatureProfile`
19//! so the dispatcher can apply the same gate-check pattern
20//! across protocols. A future revision that adds a new
21//! feature gains a new variant in [`AcpFeature`] and a row in
22//! the matrix; the type stays the same.
23
24use crate::domain::acp::version::AcpVersion;
25
26/// The count of features enumerated by [`AcpFeature`].
27///
28/// The constant is the source of truth for the column count
29/// of the matrix; adding a variant to [`AcpFeature`] is the
30/// only edit this constant needs to pick the new column up.
31/// A unit test enforces that the variant count matches the
32/// constant.
33pub const N_FEATURES: usize = 7;
34
35/// A capability an ACP peer can advertise. The variant
36/// carries the spec section that introduced it; the
37/// `feature_matrix_golden` test names the source.
38///
39/// # Why `Copy`
40///
41/// The matrix is `[[bool; N_FEATURES]; N_VERSIONS]` — a
42/// per-revision row, indexed by `AcpFeature::ALL`. The
43/// `ALL` array is the source of truth for the column count
44/// and is a stack-local constant. The `Copy` derive keeps
45/// the per-cell copies free.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum AcpFeature {
48    /// `session/prompt` — the user-turn call. Every ACP
49    /// agent the v1.25.0 schema describes supports
50    /// `session/prompt`; the matrix row for v1.25.0 is `true`.
51    /// Reference:
52    /// <https://github.com/agentclientprotocol/agent-client-protocol/blob/schema-v1.25.0/schema/v1/schema.json>
53    /// (the `agentMethods.session_prompt` entry in
54    /// `meta.json`).
55    SessionPrompt,
56    /// `session/cancel` — cancels an in-flight `session/prompt`.
57    /// Reference: `meta.json::agentMethods.session_cancel`.
58    SessionCancel,
59    /// `session/load` and `session/resume` — the editor's
60    /// ability to resume a session. The agent advertises
61    /// `loadSession` in `initialize`; the matrix row is
62    /// `true` if the agent advertised it.
63    /// Reference: `meta.json::agentMethods.session_load`
64    /// and `session_resume`.
65    SessionLoad,
66    /// `fs/write_text_file` and `fs/read_text_file` — the
67    /// editor's filesystem access. The agent's
68    /// `fs.readTextFile` and `fs.writeTextFile` capabilities
69    /// gate whether the agent may call the editor.
70    /// Reference: `meta.json::clientMethods.fs_*`.
71    FsAccess,
72    /// `terminal/*` — the editor's terminal access. The
73    /// agent's `terminal` capability gates the five
74    /// `terminal/*` methods.
75    /// Reference: `meta.json::clientMethods.terminal_*`.
76    TerminalAccess,
77    /// `elicitation/*` — the editor's structured elicitation
78    /// surface. The agent's `elicitation` capability gates
79    /// the two `elicitation/*` methods.
80    /// Reference: `meta.json::clientMethods.elicitation_*`.
81    Elicitation,
82    /// `$/cancel_request` — the JSON-RPC standard cancel.
83    /// Both peers must support this; the matrix row is
84    /// always `true` and exists for symmetry with the
85    /// `meta.json::protocolMethods` table.
86    /// Reference: `meta.json::protocolMethods.cancel_request`.
87    CancelRequest,
88}
89
90impl AcpFeature {
91    /// The variant in declaration order. The order is
92    /// the index the matrix uses.
93    pub const ALL: [Self; N_FEATURES] = [
94        Self::SessionPrompt,
95        Self::SessionCancel,
96        Self::SessionLoad,
97        Self::FsAccess,
98        Self::TerminalAccess,
99        Self::Elicitation,
100        Self::CancelRequest,
101    ];
102}
103
104/// A capability profile negotiated at session startup.
105///
106/// Carries the negotiated version, the agent's
107/// capabilities, the editor's capabilities, and the
108/// per-feature bitset. The bitset is the answer to
109/// "may code path X run right now?".
110///
111/// # Why a `Copy` newtype around a fixed-size array
112///
113/// The array is `Copy` because its element is `bool`
114/// (which is `Copy`). A profile is a snapshot of the
115/// negotiation result; it is small, cheap to copy,
116/// and lives on the stack. The `AcpFeatureProfile` is
117/// what the dispatcher's gate checks against on the
118/// hot path; a `Copy` shape keeps that check an array
119/// lookup, not a pointer chase.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
121pub struct AcpFeatureProfile {
122    /// The negotiated version. `AcpVersion::Unknown`
123    /// means a future release the build has not seen
124    /// the codec for; the dispatcher still gates on
125    /// the bitset.
126    pub version: AcpVersion,
127    /// The per-feature bitset, indexed by `AcpFeature::ALL`.
128    pub features: [bool; N_FEATURES],
129}
130
131impl AcpFeatureProfile {
132    /// A profile with every feature off. Useful as a
133    /// default before negotiation completes.
134    pub const EMPTY: Self = Self {
135        version: AcpVersion::V1_25_0,
136        features: [false; N_FEATURES],
137    };
138
139    /// The canonical v1.25.0 profile: every method
140    /// `meta.json` lists is on, because the v1.25.0
141    /// schema is the union of those methods. An agent
142    /// built on this build that wants to drop a
143    /// method constructs a profile with the relevant
144    /// bit off.
145    pub const V1_25_0: Self = Self {
146        version: AcpVersion::V1_25_0,
147        features: [true; N_FEATURES],
148    };
149
150    /// Returns `true` if the feature is on. The
151    /// dispatcher gates every feature-specific code
152    /// path through this method; an off feature is
153    /// the typed error path.
154    pub const fn supports(self, feature: AcpFeature) -> bool {
155        // The match maps the variant to its
156        // bit-position. The variant-to-position
157        // mapping is the same order the `ALL`
158        // array uses, so the `feature_variant_count_matches_n_features`
159        // test is the lock.
160        match feature {
161            AcpFeature::SessionPrompt => self.features[0],
162            AcpFeature::SessionCancel => self.features[1],
163            AcpFeature::SessionLoad => self.features[2],
164            AcpFeature::FsAccess => self.features[3],
165            AcpFeature::TerminalAccess => self.features[4],
166            AcpFeature::Elicitation => self.features[5],
167            AcpFeature::CancelRequest => self.features[6],
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    /// A `Copy` value the test harness can move
177    /// freely; the matrix comparison is a `==` on
178    /// a stack-local.
179    fn assert_supports(profile: AcpFeatureProfile, feature: AcpFeature, expected: bool) {
180        assert_eq!(
181            profile.supports(feature),
182            expected,
183            "feature {feature:?} on {profile:?}"
184        );
185    }
186
187    /// The v1.25.0 canonical profile is every
188    /// feature on. A protocol-droid ACP agent built
189    /// on the pin starts with this profile and
190    /// removes features it does not implement.
191    #[test]
192    fn the_v1_25_0_canonical_profile_is_every_feature_on() {
193        let profile = AcpFeatureProfile::V1_25_0;
194        for feature in AcpFeature::ALL {
195            assert_supports(profile, feature, true);
196        }
197    }
198
199    /// The `EMPTY` profile is every feature off;
200    /// `supports` is `false` for every cell. A
201    /// pre-negotiation profile is `EMPTY` so the
202    /// gate check rejects any code path that runs
203    /// before the editor's capabilities are known.
204    #[test]
205    fn the_empty_profile_is_every_feature_off() {
206        let profile = AcpFeatureProfile::EMPTY;
207        for feature in AcpFeature::ALL {
208            assert_supports(profile, feature, false);
209        }
210    }
211
212    /// The matrix lock. The `ALL` array length
213    /// matches the `N_FEATURES` constant, so a
214    /// future variant added to the enum but
215    /// forgotten in `ALL` is caught here, not
216    /// by a downstream OOB read.
217    #[test]
218    fn feature_variant_count_matches_n_features() {
219        assert_eq!(AcpFeature::ALL.len(), N_FEATURES);
220    }
221}