Skip to main content

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}