protocol_droid/application/a2a/conformance.rs
1//! A2A v1.0 conformance vector runner.
2//!
3//! The spec publishes a set of request/response examples; this
4//! module is the loop that drives each one through an
5//! [`A2aRequestHandler`] and reports the diff. The handler can
6//! be the library's own reference implementation, a third-party
7//! server a consumer is auditing, or a fixture for a test.
8//!
9//! # Vector format
10//!
11//! A vector is a JSON object with `name`, `request`, and
12//! `expected`:
13//!
14//! ```text
15//! {"name": "send-message-happy",
16//! "request": {"jsonrpc": "2.0", "id": 1, "method": "SendMessage",
17//! "params": {"message": {...}}},
18//! "expected": {"result": {...}}}
19//! ```
20//!
21//! `expected` is one of `{"result": <value>}` for a successful
22//! response or `{"error": {"code": <i64>, "message_contains": <str>}}`
23//! for an error response. The `message_contains` form is the
24//! forgiving one: a server is free to add detail, but a runner
25//! that asserted the whole message verbatim would couple
26//! vectors to a specific implementation's wording.
27
28use std::sync::Arc;
29
30use axum::body::Body;
31use axum::http::{Request, StatusCode};
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34use tower::ServiceExt;
35
36use crate::adapters::primary::a2a::jsonrpc_serve::serve_jsonrpc;
37use crate::application::a2a::A2aRequestHandler;
38use crate::domain::a2a::A2aVersion;
39
40/// One conformance vector, exactly as it appears in a JSONL
41/// fixture file. The shape is a JSON object, one per line.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct Vector {
44 /// A short, human-readable name the runner prints
45 /// when reporting a diff. The fixture file is the
46 /// authoritative list; an operator reading the
47 /// report goes by name to find the failing case.
48 pub name: String,
49 /// The JSON-RPC envelope to send. Must carry
50 /// `jsonrpc: "2.0"`, `id`, `method`, and
51 /// `params`. The runner does not synthesise
52 /// defaults.
53 pub request: Value,
54 /// The expected response: either a `result` or
55 /// an `error` with the code and a substring the
56 /// server's message must contain.
57 pub expected: ExpectedResponse,
58}
59
60/// What the server is expected to answer. One of
61/// `Result` (a successful response) or
62/// `Error` (a JSON-RPC error with the code and a
63/// substring the message must contain).
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65#[serde(untagged)]
66pub enum ExpectedResponse {
67 /// A successful response, carrying the JSON the
68 /// server is expected to return. Compared as
69 /// `Value::eq` so a server that produces an
70 /// equal but differently-ordered object is
71 /// still a pass.
72 Result {
73 /// The JSON value the server is expected to
74 /// return. Compared as `Value::eq` so
75 /// key-order differences are tolerated.
76 result: Value,
77 },
78 /// An error response. The runner accepts any
79 /// message that contains the substring
80 /// (case-sensitive) — see the module docs.
81 Error {
82 /// The code and a substring the message
83 /// must contain. See [`ExpectedError`].
84 error: ExpectedError,
85 },
86}
87
88/// The expected error code and a substring the
89/// message must contain. The code is exact; the
90/// message is a substring match.
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
92pub struct ExpectedError {
93 /// The JSON-RPC error code the server is
94 /// expected to return. Compared with `==`.
95 pub code: i64,
96 /// A substring the server's `message` must
97 /// contain. Case-sensitive.
98 pub message_contains: String,
99}
100
101/// The outcome of running a single vector. A
102/// `ConformanceReport` is a `Vec<VectorResult>`.
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
104pub struct VectorResult {
105 /// The vector's name, copied through from
106 /// the input so a report can be grepped by
107 /// the same string an operator used in the
108 /// fixture.
109 pub name: String,
110 /// The observed outcome. A `Pass` is a
111 /// short report; a `Fail` carries the
112 /// expected and the observed so a
113 /// structured consumer can diff without
114 /// re-running.
115 pub outcome: VectorOutcome,
116}
117
118/// What the runner saw when it ran a vector.
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
120#[serde(tag = "kind", rename_all = "snake_case")]
121pub enum VectorOutcome {
122 /// The vector's expected and observed responses
123 /// matched on every field the runner checks.
124 Pass,
125 /// The vector's expected and observed responses
126 /// did not match. The `expected` and `actual`
127 /// carry the same shape, so a structured
128 /// consumer can render a diff without re-running
129 /// the request.
130 Fail {
131 /// A short, human-readable summary of the
132 /// difference. Useful for an operator
133 /// reading a CI log.
134 diff: String,
135 /// What the runner saw, in the same shape
136 /// the fixture uses for `expected`.
137 actual: ObservedResponse,
138 },
139}
140
141/// What the runner actually saw on the wire, in
142/// the same shape the fixture uses for
143/// `expected`.
144///
145/// A runner that recorded a 200 but a wrong body
146/// would still produce a `Fail`; a runner that
147/// recorded a 500 would surface the
148/// transport-level error here.
149#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
150#[serde(untagged)]
151pub enum ObservedResponse {
152 /// The server answered with a `result`.
153 Result {
154 /// The `result` field of the JSON-RPC envelope.
155 result: Value,
156 },
157 /// The server answered with an error envelope.
158 Error {
159 /// The error code and message the server returned.
160 error: ObservedError,
161 },
162 /// The transport returned a non-2xx status
163 /// with a body the JSON-RPC layer could not
164 /// parse, or returned no body at all.
165 Transport {
166 /// The HTTP status code the transport observed.
167 status: u16,
168 /// The body the transport observed, or a
169 /// description of why it could not be
170 /// parsed.
171 body: String,
172 },
173}
174
175/// The shape of an observed error: the code
176/// the server returned and the message it
177/// carried. Compared against `ExpectedError`
178/// with `==` on the code and substring on
179/// the message.
180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
181pub struct ObservedError {
182 /// The JSON-RPC error code the server returned.
183 pub code: i64,
184 /// The message the server returned.
185 pub message: String,
186}
187
188impl core::fmt::Display for ObservedResponse {
189 /// Renders the observed response in a shape a
190 /// human reading a CI log can scan. The exact
191 /// format is not part of the contract; the
192 /// `VectorOutcome::Fail::actual` field is.
193 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
194 match self {
195 Self::Result { result } => write!(formatter, "Result({result})"),
196 Self::Error { error } => {
197 write!(formatter, "Error({}, {})", error.code, error.message)
198 }
199 Self::Transport { status, body } => {
200 write!(formatter, "Transport({status}, {body})")
201 }
202 }
203 }
204}
205
206/// Runs a list of vectors against a handler, using
207/// the in-process `serve_jsonrpc` adapter.
208///
209/// The handler is the same trait a real router
210/// uses, so the vectors exercise the same code
211/// path a third-party client would. `async`
212/// because `tower::ServiceExt::oneshot` returns
213/// a future; the test suite's `#[tokio::test]`
214/// drives this directly, and a CLI subcommand
215/// that wants the same can wrap it in a
216/// `tokio::main` or in a
217/// `tokio::runtime::Handle::block_on`.
218///
219/// # Errors
220///
221/// None. A runner that returned `Result` for a
222/// transport-level error would force the caller
223/// to abort on the first network blip; the
224/// runner's contract is to produce a `VectorResult`
225/// for every input, with the failure mode named
226/// in the outcome. An error here would mean
227/// the harness itself is broken, not the
228/// server under test.
229pub async fn run_against_in_process(
230 handler: Arc<dyn A2aRequestHandler>,
231 protocol_version: A2aVersion,
232 vectors: &[Vector],
233) -> Vec<VectorResult> {
234 let router = serve_jsonrpc("/a2a", handler, protocol_version);
235 let mut out = Vec::with_capacity(vectors.len());
236 for vector in vectors {
237 out.push(run_one(&router, vector).await);
238 }
239 out
240}
241
242/// Runs a single vector through the supplied router.
243///
244/// Public so a custom harness (one that wants to
245/// drive against a real socket rather than
246/// `tower::ServiceExt::oneshot`) can reuse the
247/// response-shaping logic.
248pub async fn run_one(router: &axum::Router, vector: &Vector) -> VectorResult {
249 let body_bytes = match serde_json::to_vec(&vector.request) {
250 Ok(bytes) => bytes,
251 Err(error) => {
252 return VectorResult {
253 name: vector.name.clone(),
254 outcome: VectorOutcome::Fail {
255 diff: format!("the request could not be serialised: {error}"),
256 actual: ObservedResponse::Transport {
257 status: 0,
258 body: format!("encode: {error}"),
259 },
260 },
261 };
262 }
263 };
264 let request = match Request::builder()
265 .method("POST")
266 .uri("/a2a")
267 .header("content-type", "application/json")
268 .body(Body::from(body_bytes))
269 {
270 Ok(request) => request,
271 Err(error) => {
272 return VectorResult {
273 name: vector.name.clone(),
274 outcome: VectorOutcome::Fail {
275 diff: format!("the harness built a request the http layer refused: {error}"),
276 actual: ObservedResponse::Transport {
277 status: 0,
278 body: format!("build: {error}"),
279 },
280 },
281 };
282 }
283 };
284 let response = match router.clone().oneshot(request).await {
285 Ok(response) => response,
286 // The router's error type is `Infallible`;
287 // a 500 is a `Response`, not an `Err`. The
288 // `match never {}` arm is the
289 // type-system-asserted "this is impossible",
290 // and a lint that says otherwise is reading
291 // the future incompatibility wrong.
292 Err(never) => match never {},
293 };
294 let observed = observe_response(response).await;
295 compare(vector, &observed)
296}
297
298/// Reads a JSON-RPC response body, extracts the
299/// envelope, and shapes it for the runner's
300/// `ObservedResponse`. Status codes outside
301/// the 200 range are recorded as `Transport`.
302async fn observe_response(response: axum::response::Response) -> ObservedResponse {
303 let status = response.status();
304 let body_bytes = match axum::body::to_bytes(response.into_body(), 64 * 1024).await {
305 Ok(bytes) => bytes,
306 Err(error) => {
307 return ObservedResponse::Transport {
308 status: status.as_u16(),
309 body: format!("read body: {error}"),
310 };
311 }
312 };
313 let body_text = String::from_utf8_lossy(&body_bytes).into_owned();
314 if status != StatusCode::OK {
315 return ObservedResponse::Transport {
316 status: status.as_u16(),
317 body: body_text,
318 };
319 }
320 let envelope: Value = match serde_json::from_slice(&body_bytes) {
321 Ok(value) => value,
322 Err(error) => {
323 return ObservedResponse::Transport {
324 status: status.as_u16(),
325 body: format!("invalid JSON-RPC envelope: {error}; body: {body_text}"),
326 };
327 }
328 };
329 let Some(envelope_object) = envelope.as_object() else {
330 return ObservedResponse::Transport {
331 status: status.as_u16(),
332 body: format!("envelope was not a JSON object: {envelope}"),
333 };
334 };
335 envelope_object.get("result").map_or_else(
336 || {
337 envelope_object.get("error").map_or_else(
338 || ObservedResponse::Transport {
339 status: status.as_u16(),
340 body: format!("envelope carried neither result nor error: {envelope}"),
341 },
342 |error| {
343 let code = error.get("code").and_then(Value::as_i64).unwrap_or(0);
344 let message = error
345 .get("message")
346 .and_then(Value::as_str)
347 .unwrap_or("")
348 .to_owned();
349 ObservedResponse::Error {
350 error: ObservedError { code, message },
351 }
352 },
353 )
354 },
355 |result| ObservedResponse::Result {
356 result: result.clone(),
357 },
358 )
359}
360
361/// Compares a vector's expected response against
362/// what the runner observed. The check is exact
363/// on the success path (`Value::eq` after
364/// canonicalising) and exact on the code plus
365/// substring on the message for the error path.
366fn compare(vector: &Vector, observed: &ObservedResponse) -> VectorResult {
367 let pass = match (&vector.expected, observed) {
368 (ExpectedResponse::Result { result: expected }, ObservedResponse::Result { result }) => {
369 expected == result
370 }
371 (
372 ExpectedResponse::Error {
373 error:
374 ExpectedError {
375 code,
376 message_contains,
377 },
378 },
379 ObservedResponse::Error {
380 error:
381 ObservedError {
382 code: obs_code,
383 message,
384 },
385 },
386 ) => code == obs_code && message.contains(message_contains.as_str()),
387 _ => false,
388 };
389 if pass {
390 VectorResult {
391 name: vector.name.clone(),
392 outcome: VectorOutcome::Pass,
393 }
394 } else {
395 VectorResult {
396 name: vector.name.clone(),
397 outcome: VectorOutcome::Fail {
398 diff: format_diff(&vector.expected, observed),
399 actual: observed.clone(),
400 },
401 }
402 }
403}
404
405/// Renders a short, human-readable summary of the
406/// difference between an expected and an observed
407/// response. A structured consumer should diff the
408/// `actual` field instead of parsing this string,
409/// but a CI log without a JSON-aware viewer needs
410/// something it can grep.
411fn format_diff(expected: &ExpectedResponse, observed: &ObservedResponse) -> String {
412 match (expected, observed) {
413 (_, ObservedResponse::Transport { status, body }) => {
414 format!("the transport answered {status} with body {body}")
415 }
416 (ExpectedResponse::Error { error: expected }, ObservedResponse::Error { error }) => {
417 format!(
418 "expected an error with code {} (containing {:?}), got code {} message {}",
419 expected.code, expected.message_contains, error.code, error.message
420 )
421 }
422 (ExpectedResponse::Error { error: expected }, ObservedResponse::Result { result }) => {
423 format!(
424 "expected an error with code {} (containing {:?}), got result {}",
425 expected.code, expected.message_contains, result
426 )
427 }
428 (ExpectedResponse::Result { result: expected }, ObservedResponse::Result { result }) => {
429 format!("expected result {expected}, got {result}")
430 }
431 (ExpectedResponse::Result { .. }, ObservedResponse::Error { error }) => {
432 format!(
433 "expected a result, got an error envelope with code {} message {}",
434 error.code, error.message
435 )
436 }
437 }
438}
439
440/// Parses a JSONL fixture file's contents into a
441/// list of vectors.
442///
443/// The format is one JSON object per line; a blank
444/// line or a line whose first non-whitespace
445/// character is `#` is ignored, so fixtures can
446/// carry a license header.
447///
448/// # Errors
449///
450/// Returns the line number (1-indexed) of the first
451/// malformed line. An empty file is not an error;
452/// a file with only comments and blank lines is the
453/// same as empty. The runner treats an empty list
454/// as "all pass" so a CI job that does not have the
455/// fixture file can ship with a no-op run.
456pub fn parse_jsonl(contents: &str) -> Result<Vec<Vector>, JsonlError> {
457 let mut out = Vec::new();
458 for (line_number, line) in contents.lines().enumerate() {
459 let trimmed = line.trim();
460 if trimmed.is_empty() || trimmed.starts_with('#') {
461 continue;
462 }
463 let vector: Vector = serde_json::from_str(trimmed).map_err(|error| JsonlError {
464 line: line_number + 1,
465 message: format!("{error}"),
466 })?;
467 out.push(vector);
468 }
469 Ok(out)
470}
471
472/// Why a JSONL fixture could not be parsed.
473#[derive(Debug, Clone, PartialEq, Eq)]
474pub struct JsonlError {
475 /// The 1-indexed line number of the first
476 /// malformed line in the fixture.
477 pub line: usize,
478 /// The serde error message, formatted with
479 /// the standard Display impl.
480 pub message: String,
481}
482
483impl core::fmt::Display for JsonlError {
484 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
485 write!(f, "line {}: {}", self.line, self.message)
486 }
487}
488
489impl std::error::Error for JsonlError {}
490
491/// The default protocol version the runner uses
492/// when the caller does not specify one. A2A's
493/// 1.0.1 is a patch (no schema changes), so 1.0
494/// is the right default for vectors that pin the
495/// spec's behaviour.
496pub const DEFAULT_PROTOCOL_VERSION: A2aVersion = A2aVersion::V1_0;
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 /// The vector types are reachable from the
503 /// library's own test target. Without this
504 /// test, the lib's `unused_pub` lint would
505 /// flag the public API as unused even though
506 /// the integration test in
507 /// `tests/a2a_conformance.rs` is the actual
508 /// consumer.
509 #[test]
510 fn the_public_types_are_constructible() {
511 let vector = Vector {
512 name: "sample".to_owned(),
513 request: serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"}),
514 expected: ExpectedResponse::Result {
515 result: serde_json::json!({}),
516 },
517 };
518 let result = VectorResult {
519 name: vector.name,
520 outcome: VectorOutcome::Pass,
521 };
522 let _ = ObservedResponse::Result {
523 result: serde_json::json!({}),
524 };
525 let _ = ObservedResponse::Error {
526 error: ObservedError {
527 code: -32601,
528 message: "Method not found".to_owned(),
529 },
530 };
531 let _ = ObservedResponse::Transport {
532 status: 500,
533 body: "internal".to_owned(),
534 };
535 assert_eq!(result.name, "sample");
536 assert_eq!(DEFAULT_PROTOCOL_VERSION, A2aVersion::V1_0);
537 }
538
539 /// `parse_jsonl` round-trips a known-good
540 /// fixture, so the lib has direct coverage
541 /// independent of the integration test's
542 /// fixture file.
543 #[test]
544 fn parse_jsonl_round_trips_a_known_good_fixture() -> Result<(), JsonlError> {
545 let fixture = "{\"name\": \"a\", \"request\": {}, \"expected\": {\"result\": {}}}\n{\"name\": \"b\", \"request\": {}, \"expected\": {\"error\": {\"code\": -1, \"message_contains\": \"x\"}}}\n";
546 let vectors = parse_jsonl(fixture)?;
547 assert_eq!(vectors.len(), 2);
548 assert_eq!(vectors.first().map(|v| v.name.as_str()), Some("a"));
549 assert_eq!(vectors.get(1).map(|v| v.name.as_str()), Some("b"));
550 Ok(())
551 }
552
553 /// `parse_jsonl` returns the line number of
554 /// the malformed entry, 1-indexed.
555 #[test]
556 fn parse_jsonl_names_the_offending_line() -> Result<(), String> {
557 let fixture =
558 "{\"name\": \"ok\", \"request\": {}, \"expected\": {\"result\": {}}}\nbad json\n";
559 match parse_jsonl(fixture) {
560 Ok(vectors) => Err(format!(
561 "a malformed fixture should be an error, got {vectors_len} vectors",
562 vectors_len = vectors.len()
563 )),
564 Err(error) => {
565 if error.line == 2 {
566 Ok(())
567 } else {
568 Err(format!(
569 "the line number should be 2, got {line}",
570 line = error.line
571 ))
572 }
573 }
574 }
575 }
576
577 /// `JsonlError`'s `Display` impl carries the
578 /// line number and the serde message in a
579 /// single line, so a CI log without a JSON
580 /// viewer can still grep the failure.
581 #[test]
582 fn jsonl_error_renders_one_line() {
583 let error = JsonlError {
584 line: 3,
585 message: "expected `,` at line 1".to_owned(),
586 };
587 let text = format!("{error}");
588 assert!(text.contains('3'), "the line number should appear: {text}");
589 assert!(
590 text.contains("expected"),
591 "the serde message should appear: {text}"
592 );
593 }
594
595 /// `run_against_in_process` is exercised in
596 /// the integration test, but a one-vector
597 /// unit test here proves the function is
598 /// reachable from the lib's test target.
599 #[tokio::test]
600 async fn run_against_in_process_runs_zero_vectors() -> Result<(), &'static str> {
601 let report = run_against_in_process(
602 Arc::new(DeterministicAgentForUnit) as Arc<dyn A2aRequestHandler>,
603 A2aVersion::V1_0,
604 &[],
605 )
606 .await;
607 if report.is_empty() {
608 Ok(())
609 } else {
610 Err("an empty input should produce an empty report")
611 }
612 }
613
614 /// `run_one` is exercised in the integration
615 /// test, but a one-vector unit test here
616 /// proves the function is reachable from the
617 /// lib's test target.
618 #[tokio::test]
619 async fn run_one_produces_a_pass_for_a_well_known_response() {
620 let vector = Vector {
621 name: "sample".to_owned(),
622 request: serde_json::json!({
623 "jsonrpc": "2.0",
624 "id": 1,
625 "method": "NonExistent",
626 "params": {}
627 }),
628 expected: ExpectedResponse::Error {
629 error: ExpectedError {
630 code: -32601,
631 message_contains: "Method not found".to_owned(),
632 },
633 },
634 };
635 let router = crate::adapters::primary::a2a::jsonrpc_serve::serve_jsonrpc(
636 "/a2a",
637 Arc::new(DeterministicAgentForUnit) as Arc<dyn A2aRequestHandler>,
638 A2aVersion::V1_0,
639 );
640 let result = run_one(&router, &vector).await;
641 assert!(matches!(result.outcome, VectorOutcome::Pass));
642 }
643
644 /// A unit-test agent. Every method returns a
645 /// `MethodNotImplemented` so the lib's test
646 /// target can drive `run_one` with a known
647 /// error shape without having to build a
648 /// full reference agent.
649 struct DeterministicAgentForUnit;
650
651 impl A2aRequestHandler for DeterministicAgentForUnit {
652 fn dispatch(
653 &self,
654 _method: crate::adapters::secondary::a2a::A2aMethod,
655 _params: Option<Value>,
656 _context: &crate::application::a2a::A2aRequestContext,
657 ) -> std::pin::Pin<
658 Box<
659 dyn std::future::Future<Output = Result<Value, crate::application::a2a::A2aError>>
660 + Send
661 + '_,
662 >,
663 > {
664 Box::pin(async move { Err(crate::application::a2a::A2aError::MethodNotFound) })
665 }
666 }
667}