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}