Skip to main content

protocol_droid/domain/a2a/
feature.rs

1//! `A2aFeatureProfile` — the per-revision capability bitset, derived
2//! from the same four inputs as the MCP profile.
3//!
4//! # Why a separate profile type from MCP
5//!
6//! MCP's `FeatureProfile` and A2A's `A2aFeatureProfile` are
7//! independent types because the features they enumerate are
8//! independent. A bridge that translates MCP↔A2A maps the *union*
9//! of the two — the bridge's domain code carries both profiles
10//! side-by-side; it does not unify them into one bitset.
11//!
12//! # What this is not
13//!
14//! This is not the place to enumerate every A2A feature. The
15//! enumeration in `Feature` below is the v1.0 minimum: the
16//! methods the spec lists, the streaming shape, and the
17//! push-notification delivery model. A future revision that adds
18//! a new method gains a new variant and a row in the matrix; the
19//! type stays the same.
20
21use crate::domain::a2a::version::A2aVersion;
22
23/// A capability an A2A peer can negotiate. Each variant has a
24/// row in `tests::feature_matrix_golden` that names the
25/// A2A spec section that introduced it.
26///
27/// # Why a `Copy` newtype for the matrix
28///
29/// The matrix is a `[[bool; N_FEATURES]; N_VERSIONS]` — a
30/// per-revision row, indexed by `Feature::ALL`. The `ALL`
31/// array is the source of truth for the column count, so a new
32/// variant appended to the enum is the only edit the matrix
33/// needs to pick it up. The `Copy` derive keeps the
34/// `Feature::ALL` array a stack-local constant.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum Feature {
37    /// `SendMessage` — the unary message-send method.
38    SendMessage,
39    /// `SendStreamingMessage` — the streaming counterpart of
40    /// `SendMessage`, server-sent events on a Streamable HTTP
41    /// POST.
42    SendStreamingMessage,
43    /// `GetTask` — fetch a task by id.
44    GetTask,
45    /// `ListTasks` — page through the tasks the agent owns.
46    ListTasks,
47    /// `CancelTask` — request cancellation of an in-flight task.
48    CancelTask,
49    /// `SubscribeToTask` — long-poll or SSE-style subscription to
50    /// task updates.
51    SubscribeToTask,
52    /// Push-notification config CRUD — the four `Create*`,
53    /// `Get*`, `List*`, `Delete*` methods.
54    PushNotificationConfig,
55    /// `GetExtendedAgentCard` — fetch the authenticated
56    /// `AgentCard` once the peer has proven its identity.
57    GetExtendedAgentCard,
58}
59
60impl Feature {
61    /// Every variant, in declaration order. The matrix's
62    /// column order is this order; a variant appended here
63    /// becomes a new column without a separate edit.
64    pub const ALL: [Self; 8] = [
65        Self::SendMessage,
66        Self::SendStreamingMessage,
67        Self::GetTask,
68        Self::ListTasks,
69        Self::CancelTask,
70        Self::SubscribeToTask,
71        Self::PushNotificationConfig,
72        Self::GetExtendedAgentCard,
73    ];
74
75    /// The human-readable label used in the matrix snapshot and
76    /// in the `Display` form. Kept here rather than derived so a
77    /// future variant that needs a different label (e.g. for
78    /// `camelCase` ↔ `snake_case`) can hold one without changing
79    /// the matrix's column header.
80    pub const fn label(&self) -> &'static str {
81        match self {
82            Self::SendMessage => "SendMessage",
83            Self::SendStreamingMessage => "SendStreamingMessage",
84            Self::GetTask => "GetTask",
85            Self::ListTasks => "ListTasks",
86            Self::CancelTask => "CancelTask",
87            Self::SubscribeToTask => "SubscribeToTask",
88            Self::PushNotificationConfig => "PushNotificationConfig",
89            Self::GetExtendedAgentCard => "GetExtendedAgentCard",
90        }
91    }
92}
93
94/// The per-revision capability bitset for an A2A peer.
95///
96/// The matrix is small (one row per known version, one column
97/// per `Feature::ALL`); the type holds it as a fixed-size
98/// `[bool; N_FEATURES]` so the matrix lives in the type
99/// system rather than in a `HashMap` that the feature set could
100/// silently diverge from.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct A2aFeatureProfile {
103    /// The version this profile was derived for.
104    version: A2aVersion,
105    /// One bit per `Feature::ALL`, in the same order.
106    bitset: [bool; 8],
107}
108
109impl A2aFeatureProfile {
110    /// Build the profile for a known version. The matrix is the
111    /// authoritative source; a per-version row is a literal
112    /// here. A future revision adds a row, not a column.
113    pub const fn for_known_version(version: A2aVersion) -> Self {
114        match version {
115            A2aVersion::V1_0 => Self {
116                version,
117                // Every v1.0 method is supported. The streaming
118                // shape is gated by transport, not by version
119                // — the profile here is the *capability*, the
120                // transport is consulted separately.
121                bitset: [true; 8],
122            },
123            A2aVersion::Unknown { major: _, minor: _ } => Self {
124                version,
125                // An unknown version is a peer the matrix has
126                // not been updated for. The conservative default
127                // is "no features" so a feature-specific code
128                // path consults the profile and falls through to
129                // the typed error, not to a silent
130                // success-on-typo. The peer that names a
131                // `VersionNotSupportedError` upgrade is the
132                // upstream-correct path.
133                bitset: [false; 8],
134            },
135        }
136    }
137
138    /// `true` if the named feature is enabled. The match is on
139    /// a fixed-size array indexed by the feature's position in
140    /// `Feature::ALL`; a new variant appended to `ALL` is the
141    /// only edit the index needs to pick up.
142    pub const fn is_enabled(&self, feature: Feature) -> bool {
143        // `feature_index` returns a value in `0..Feature::ALL.len()`,
144        // and `self.bitset` is sized to that constant. The match in
145        // `feature_index` is exhaustive on `Feature`, so a future
146        // variant added to the enum without an index entry is a
147        // compile error rather than a runtime out-of-bounds.
148        match feature {
149            Feature::SendMessage => self.bitset[0],
150            Feature::SendStreamingMessage => self.bitset[1],
151            Feature::GetTask => self.bitset[2],
152            Feature::ListTasks => self.bitset[3],
153            Feature::CancelTask => self.bitset[4],
154            Feature::SubscribeToTask => self.bitset[5],
155            Feature::PushNotificationConfig => self.bitset[6],
156            Feature::GetExtendedAgentCard => self.bitset[7],
157        }
158    }
159
160    /// The version this profile was derived for. The
161    /// dispatcher's `VersionNotSupportedError` includes this so
162    /// a peer that did not negotiate sees which version it
163    /// landed on.
164    pub const fn version(&self) -> A2aVersion {
165        self.version
166    }
167}
168
169/// Compile-time feature match. The match in
170/// `A2aFeatureProfile::is_enabled` is exhaustive on every
171/// variant of `Feature`, so a future variant added to the
172/// enum without an entry is a compile error rather than a
173/// runtime out-of-bounds.
174const fn _ensure_feature_match_is_exhaustive(feature: Feature) -> usize {
175    match feature {
176        Feature::SendMessage => 0,
177        Feature::SendStreamingMessage => 1,
178        Feature::GetTask => 2,
179        Feature::ListTasks => 3,
180        Feature::CancelTask => 4,
181        Feature::SubscribeToTask => 5,
182        Feature::PushNotificationConfig => 6,
183        Feature::GetExtendedAgentCard => 7,
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::{A2aFeatureProfile, Feature};
190    use crate::domain::a2a::version::A2aVersion;
191
192    /// `Feature::ALL` is the source of
193    /// truth for the matrix column
194    /// count and ordering. A new
195    /// variant appended to the enum
196    /// must also be appended to `ALL`
197    /// for the matrix to pick it up;
198    /// the test pins the current
199    /// shape (eight features, in the
200    /// order the spec introduces
201    /// them).
202    #[test]
203    fn feature_all_lists_eight_features_in_order() {
204        assert_eq!(Feature::ALL.len(), 8);
205        assert_eq!(Feature::ALL[0], Feature::SendMessage);
206        assert_eq!(Feature::ALL[1], Feature::SendStreamingMessage);
207        assert_eq!(Feature::ALL[2], Feature::GetTask);
208        assert_eq!(Feature::ALL[3], Feature::ListTasks);
209        assert_eq!(Feature::ALL[4], Feature::CancelTask);
210        assert_eq!(Feature::ALL[5], Feature::SubscribeToTask);
211        assert_eq!(Feature::ALL[6], Feature::PushNotificationConfig);
212        assert_eq!(Feature::ALL[7], Feature::GetExtendedAgentCard);
213    }
214
215    /// `label()` returns a non-empty
216    /// string per variant. The label
217    /// is the column header in the
218    /// matrix snapshot and the
219    /// `Display` form for diagnostics;
220    /// a regression that returned the
221    /// empty string would render an
222    /// empty cell.
223    #[test]
224    fn feature_label_is_non_empty_for_every_variant() {
225        for feature in Feature::ALL {
226            assert!(
227                !feature.label().is_empty(),
228                "{feature:?} has an empty label"
229            );
230        }
231    }
232
233    /// `A2aFeatureProfile::for_known_version(V1_0)`
234    /// enables every feature. The
235    /// v1.0 row in the matrix is the
236    /// all-`true` row, and a future
237    /// revision that adds a feature
238    /// will add a column with the
239    /// right value for v1.0.
240    #[test]
241    fn v1_0_profile_enables_every_feature() {
242        let profile = A2aFeatureProfile::for_known_version(A2aVersion::V1_0);
243        for feature in Feature::ALL {
244            assert!(
245                profile.is_enabled(feature),
246                "{feature:?} should be enabled in V1_0"
247            );
248        }
249    }
250
251    /// `A2aFeatureProfile::for_known_version(Unknown)`
252    /// disables every feature. An
253    /// unknown version is a peer the
254    /// matrix has not been updated
255    /// for; the conservative default
256    /// is "no features" so a
257    /// feature-specific code path is
258    /// never silently enabled for a
259    /// version the consumer has not
260    /// audited. A regression that
261    /// enabled features for an
262    /// `Unknown` would ship a wire
263    /// shape the build cannot encode.
264    #[test]
265    fn unknown_version_profile_disables_every_feature() {
266        let profile =
267            A2aFeatureProfile::for_known_version(A2aVersion::Unknown { major: 2, minor: 5 });
268        for feature in Feature::ALL {
269            assert!(
270                !profile.is_enabled(feature),
271                "{feature:?} should be disabled for an Unknown version"
272            );
273        }
274    }
275
276    /// `is_enabled` returns the
277    /// bitset value at the feature's
278    /// index in `Feature::ALL`. A
279    /// regression that returned the
280    /// wrong bit (e.g. `SendMessage`
281    /// reporting the value of
282    /// `GetTask`) would silently
283    /// route requests the build
284    /// cannot serve.
285    #[test]
286    fn is_enabled_returns_the_bit_at_the_feature_index() {
287        let profile = A2aFeatureProfile::for_known_version(A2aVersion::V1_0);
288        // The V1_0 row is all `true`,
289        // so a spot check that every
290        // feature reports enabled
291        // catches an index mix-up.
292        assert!(profile.is_enabled(Feature::SendMessage));
293        assert!(profile.is_enabled(Feature::GetExtendedAgentCard));
294        // An unknown version is all
295        // `false`; the same check
296        // catches a constant-return
297        // regression.
298        let unknown =
299            A2aFeatureProfile::for_known_version(A2aVersion::Unknown { major: 2, minor: 0 });
300        assert!(!unknown.is_enabled(Feature::SendMessage));
301        assert!(!unknown.is_enabled(Feature::GetExtendedAgentCard));
302    }
303
304    /// `version()` returns the
305    /// version the profile was
306    /// derived for. The
307    /// dispatcher's
308    /// `VersionNotSupportedError`
309    /// includes this, so a peer that
310    /// did not negotiate sees which
311    /// version it landed on. A
312    /// regression that returned
313    /// `V1_0` for an `Unknown` would
314    /// lie to the peer about what the
315    /// server actually speaks.
316    #[test]
317    fn version_returns_the_profile_version() {
318        let v1 = A2aFeatureProfile::for_known_version(A2aVersion::V1_0);
319        assert_eq!(v1.version(), A2aVersion::V1_0);
320
321        let unknown =
322            A2aFeatureProfile::for_known_version(A2aVersion::Unknown { major: 2, minor: 5 });
323        assert_eq!(
324            unknown.version(),
325            A2aVersion::Unknown { major: 2, minor: 5 }
326        );
327    }
328}