Skip to main content

mockserver_client/
a2a.rs

1//! Fluent builder for mocking an A2A (Agent-to-Agent) protocol server.
2//!
3//! This is an idiomatic Rust port of the Java client `A2aMockBuilder`
4//! (`org.mockserver.client.A2aMockBuilder`). It produces the same wire-level
5//! expectation JSON: a set of HTTP expectations that emulate an A2A agent —
6//! a discoverable agent card plus a JSON-RPC 2.0 task endpoint.
7//!
8//! Each generated expectation either:
9//!
10//! - serves the agent card on `GET <agentCardPath>` (a static JSON response), or
11//! - matches a JSON-RPC method (`tasks/send`, `tasks/get`, `tasks/cancel`,
12//!   `tasks/pushNotificationConfig/set`, or the streaming method) on
13//!   `POST <path>` and responds with a Velocity template that echoes the
14//!   incoming JSON-RPC id via `$!{request.jsonRpcRawId}`.
15//!
16//! Optional capabilities mirror the Java builder exactly:
17//!
18//! - **streaming** — advertises `capabilities.streaming: true` and adds an SSE
19//!   expectation for the streaming method (default `message/stream`) that emits
20//!   a `working` status-update, an artifact-update, then a `completed`
21//!   (`final: true`) status-update, each wrapped in a JSON-RPC 2.0 envelope.
22//! - **push notifications** — advertises `capabilities.pushNotifications: true`,
23//!   echoes the registered config from `tasks/pushNotificationConfig/set`, and
24//!   replaces the plain `tasks/send` expectation with an
25//!   override-forwarded-request that POSTs the completed task to the configured
26//!   webhook while still returning the JSON-RPC task response to the caller.
27//!
28//! # Example
29//!
30//! ```
31//! use mockserver_client::a2a::a2a_mock_default;
32//!
33//! let expectations = a2a_mock_default()
34//!     .with_agent_name("TestAgent")
35//!     .with_skill("translate")
36//!         .with_name("Translation")
37//!         .with_description("Translates text")
38//!         .with_tag("i18n")
39//!         .and()
40//!     .on_task_send()
41//!         .matching_message("translate.*")
42//!         .responding_with("Bonjour", false)
43//!         .and()
44//!     .build();
45//!
46//! // agent card + custom handler + tasks/send + tasks/get + tasks/cancel
47//! assert_eq!(expectations.len(), 5);
48//! ```
49
50use serde_json::{json, Value};
51
52use crate::error::Result;
53use crate::mcp::{escape_json, escape_velocity};
54use crate::MockServerClient;
55
56// ---------------------------------------------------------------------------
57// Escaping / template helpers — ported 1:1 from the Java builder so the
58// produced template strings are byte-identical to the other clients.
59// ---------------------------------------------------------------------------
60
61/// Like [`escape_velocity`] composed with [`escape_json`] — applied to inlined
62/// string values destined for a Velocity-templated body, exactly as in the
63/// reference clients (`escapeVelocity(escapeJson(value))`).
64fn ev_ej(value: &str) -> String {
65    escape_velocity(&escape_json(value))
66}
67
68/// Build the Velocity template string that renders a JSON-RPC 2.0 success
69/// response wrapping the supplied result JSON. Mirrors Java's
70/// `velocityJsonRpcResponse`.
71fn velocity_json_rpc_response(result_json: &str) -> String {
72    format!(
73        "{{\"statusCode\": 200, \
74\"headers\": [{{\"name\": \"Content-Type\", \"values\": [\"application/json\"]}}], \
75\"body\": {{\"jsonrpc\": \"2.0\", \"result\": {result_json}, \"id\": $!{{request.jsonRpcRawId}}}}}}"
76    )
77}
78
79/// The task `result` JSON, with `escaped_text` already escaped appropriately for
80/// the destination context. Mirrors Java's `taskResultJson`.
81fn task_result_json(escaped_text: &str, is_error: bool) -> String {
82    let state = if is_error { "failed" } else { "completed" };
83    format!(
84        "{{\"id\": \"mock-task-id\", \
85\"status\": {{\"state\": \"{state}\"}}, \
86\"artifacts\": [{{\"parts\": [{{\"type\": \"text\", \"text\": \"{escaped_text}\"}}]}}]}}"
87    )
88}
89
90/// Task result for Velocity-templated bodies — the text must survive the
91/// Velocity engine, so both JSON and Velocity escaping are applied. Mirrors
92/// Java's `buildTaskResultJson`.
93fn build_task_result_json(response_text: &str, is_error: bool) -> String {
94    task_result_json(&ev_ej(response_text), is_error)
95}
96
97/// Task result for literal (non-templated) bodies (e.g. the webhook POST
98/// payload), where no Velocity engine runs — only JSON escaping is applied, as
99/// Velocity escaping would corrupt any `$` / `#`. Mirrors Java's
100/// `buildTaskResultJsonRaw`.
101fn build_task_result_json_raw(response_text: &str, is_error: bool) -> String {
102    task_result_json(&escape_json(response_text), is_error)
103}
104
105// ---------------------------------------------------------------------------
106// Expectation fragment helpers
107// ---------------------------------------------------------------------------
108
109fn json_rpc_request(path: &str, method: &str) -> Value {
110    json!({
111        "method": "POST",
112        "path": path,
113        "body": { "type": "JSON_RPC", "method": method }
114    })
115}
116
117fn json_path_request(path: &str, json_path: &str) -> Value {
118    json!({
119        "method": "POST",
120        "path": path,
121        "body": { "type": "JSON_PATH", "jsonPath": json_path }
122    })
123}
124
125fn velocity_template_expectation(http_request: Value, result_json: &str) -> Value {
126    json!({
127        "httpRequest": http_request,
128        "httpResponseTemplate": {
129            "template": velocity_json_rpc_response(result_json),
130            "templateType": "VELOCITY"
131        }
132    })
133}
134
135// ---------------------------------------------------------------------------
136// Internal definitions
137// ---------------------------------------------------------------------------
138
139#[derive(Debug, Clone)]
140struct SkillDef {
141    id: String,
142    name: Option<String>,
143    description: Option<String>,
144    tags: Vec<String>,
145    examples: Vec<String>,
146}
147
148#[derive(Debug, Clone)]
149struct TaskHandler {
150    message_pattern: String,
151    response_text: String,
152    is_error: bool,
153}
154
155/// A parsed push-notification webhook target — host, port, scheme, and path.
156/// Mirrors Java's `WebhookTarget`.
157#[derive(Debug, Clone)]
158struct WebhookTarget {
159    host: String,
160    port: u16,
161    secure: bool,
162    path: String,
163}
164
165impl WebhookTarget {
166    fn host_header(&self) -> String {
167        format!("{}:{}", self.host, self.port)
168    }
169
170    /// Parse an absolute webhook URL into its components, applying default ports
171    /// (80 for http, 443 for https) and a default path of `/`.
172    ///
173    /// # Errors
174    /// Returns [`crate::Error`] if the URL has no host or an unparseable port.
175    fn parse(url: &str) -> Result<Self> {
176        // Minimal URL parsing sufficient for absolute http(s) webhook URLs,
177        // mirroring the subset of java.net.URI behaviour the Java builder relies
178        // on. Avoids pulling in a URL-parsing dependency.
179        let (scheme, rest) = url
180            .split_once("://")
181            .ok_or_else(|| crate::Error::InvalidRequest(format!(
182                "Invalid push-notification webhook URL (no scheme): {url}"
183            )))?;
184        let secure = scheme.eq_ignore_ascii_case("https");
185
186        // Authority is everything up to the first '/', '?' or '#'.
187        let authority_end = rest
188            .find(['/', '?', '#'])
189            .unwrap_or(rest.len());
190        let authority = &rest[..authority_end];
191        let path_and_rest = &rest[authority_end..];
192
193        // Strip userinfo if present.
194        let host_port = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp);
195
196        let (host, port) = match host_port.rsplit_once(':') {
197            // Guard against IPv6-style hosts that contain ':' but no port — the
198            // webhook URLs in practice are host[:port]; treat a non-numeric tail
199            // as part of the host.
200            Some((h, p)) if !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()) => {
201                let port = p.parse::<u16>().map_err(|_| {
202                    crate::Error::InvalidRequest(format!(
203                        "Invalid push-notification webhook URL (bad port): {url}"
204                    ))
205                })?;
206                (h.to_string(), port)
207            }
208            _ => (
209                host_port.to_string(),
210                if secure { 443 } else { 80 },
211            ),
212        };
213
214        if host.is_empty() {
215            return Err(crate::Error::InvalidRequest(format!(
216                "Invalid push-notification webhook URL (no host): {url}"
217            )));
218        }
219
220        // Path excludes any query/fragment; default to "/" when empty.
221        let path = path_and_rest
222            .split(['?', '#'])
223            .next()
224            .unwrap_or("");
225        let path = if path.is_empty() { "/".to_string() } else { path.to_string() };
226
227        Ok(Self { host, port, secure, path })
228    }
229}
230
231// ---------------------------------------------------------------------------
232// A2aMockBuilder
233// ---------------------------------------------------------------------------
234
235/// Fluent builder producing the expectations for a mocked A2A agent.
236#[derive(Debug, Clone)]
237pub struct A2aMockBuilder {
238    path: String,
239    agent_card_path: String,
240    agent_name: String,
241    agent_description: String,
242    agent_version: String,
243    agent_url: Option<String>,
244    skills: Vec<SkillDef>,
245    task_handlers: Vec<TaskHandler>,
246    default_task_response: String,
247    streaming: bool,
248    streaming_method: String,
249    push_notification_url: Option<String>,
250}
251
252impl A2aMockBuilder {
253    fn new(path: impl Into<String>) -> Self {
254        Self {
255            path: path.into(),
256            agent_card_path: "/.well-known/agent.json".to_string(),
257            agent_name: "MockAgent".to_string(),
258            agent_description: "A mock A2A agent".to_string(),
259            agent_version: "1.0.0".to_string(),
260            agent_url: None,
261            skills: Vec::new(),
262            task_handlers: Vec::new(),
263            default_task_response: "Task completed successfully".to_string(),
264            streaming: false,
265            streaming_method: "message/stream".to_string(),
266            push_notification_url: None,
267        }
268    }
269
270    /// Set the advertised agent name (default `"MockAgent"`).
271    pub fn with_agent_name(mut self, name: impl Into<String>) -> Self {
272        self.agent_name = name.into();
273        self
274    }
275
276    /// Set the advertised agent description (default `"A mock A2A agent"`).
277    pub fn with_agent_description(mut self, description: impl Into<String>) -> Self {
278        self.agent_description = description.into();
279        self
280    }
281
282    /// Set the advertised agent version (default `"1.0.0"`).
283    pub fn with_agent_version(mut self, version: impl Into<String>) -> Self {
284        self.agent_version = version.into();
285        self
286    }
287
288    /// Set the advertised agent URL. Defaults to `http://localhost<path>`.
289    pub fn with_agent_url(mut self, url: impl Into<String>) -> Self {
290        self.agent_url = Some(url.into());
291        self
292    }
293
294    /// Set the agent-card discovery path (default `/.well-known/agent.json`).
295    pub fn with_agent_card_path(mut self, path: impl Into<String>) -> Self {
296        self.agent_card_path = path.into();
297        self
298    }
299
300    /// Set the default task response text used by `tasks/send` / `tasks/get`.
301    pub fn with_default_task_response(mut self, response: impl Into<String>) -> Self {
302        self.default_task_response = response.into();
303        self
304    }
305
306    /// Advertise and mock the A2A streaming capability. The agent card reports
307    /// `capabilities.streaming: true` and the streaming method (default
308    /// `message/stream`) returns an SSE stream of status/artifact update events.
309    pub fn with_streaming(mut self) -> Self {
310        self.streaming = true;
311        self
312    }
313
314    /// Override the JSON-RPC method that triggers the streaming response (the
315    /// A2A spec uses `message/stream`; the legacy name is
316    /// `tasks/sendSubscribe`). Implies [`with_streaming`](Self::with_streaming).
317    pub fn with_streaming_method(mut self, method: impl Into<String>) -> Self {
318        self.streaming_method = method.into();
319        self.streaming = true;
320        self
321    }
322
323    /// Advertise and mock A2A push notifications. The agent card reports
324    /// `capabilities.pushNotifications: true`, `tasks/pushNotificationConfig/set`
325    /// echoes the registered config, and each `tasks/send` additionally POSTs
326    /// the completed task to the supplied webhook URL while still returning the
327    /// JSON-RPC task response to the caller.
328    pub fn with_push_notifications(mut self, webhook_url: impl Into<String>) -> Self {
329        self.push_notification_url = Some(webhook_url.into());
330        self
331    }
332
333    /// Start defining a skill. Finish with [`A2aSkillBuilder::and`].
334    pub fn with_skill(self, id: impl Into<String>) -> A2aSkillBuilder {
335        A2aSkillBuilder {
336            parent: self,
337            skill: SkillDef {
338                id: id.into(),
339                name: None,
340                description: None,
341                tags: Vec::new(),
342                examples: Vec::new(),
343            },
344        }
345    }
346
347    /// Start defining a custom `tasks/send` handler. Finish with
348    /// [`A2aTaskHandlerBuilder::and`].
349    pub fn on_task_send(self) -> A2aTaskHandlerBuilder {
350        A2aTaskHandlerBuilder {
351            parent: self,
352            message_pattern: ".*".to_string(),
353            response_text: "Task completed".to_string(),
354            is_error: false,
355        }
356    }
357
358    /// Build the full ordered list of A2A expectations.
359    ///
360    /// # Panics
361    /// Panics if [`with_push_notifications`](Self::with_push_notifications) was
362    /// given a webhook URL that cannot be parsed. Use [`try_build`](Self::try_build)
363    /// for a fallible variant.
364    pub fn build(&self) -> Vec<Value> {
365        self.try_build().expect("invalid A2A mock configuration")
366    }
367
368    /// Fallible variant of [`build`](Self::build). Returns an error only when a
369    /// configured push-notification webhook URL cannot be parsed.
370    pub fn try_build(&self) -> Result<Vec<Value>> {
371        let mut expectations = vec![self.build_agent_card()];
372
373        for handler in &self.task_handlers {
374            expectations.push(self.build_custom_task_handler(handler));
375        }
376
377        if self.streaming {
378            expectations.push(self.build_streaming());
379        }
380
381        if let Some(url) = &self.push_notification_url {
382            expectations.push(self.build_push_notification_config(url));
383            expectations.push(self.build_push_notification_delivery(url)?);
384        } else {
385            expectations.push(self.build_tasks_send());
386        }
387        expectations.push(self.build_tasks_get());
388        expectations.push(self.build_tasks_cancel());
389
390        Ok(expectations)
391    }
392
393    /// Build and register the expectations on the given client.
394    pub fn apply_to(&self, client: &MockServerClient) -> Result<Value> {
395        client.upsert_raw(Value::Array(self.try_build()?))
396    }
397
398    // --- individual expectation builders -------------------------------
399
400    fn build_agent_card(&self) -> Value {
401        let mut skills_json = String::from("[");
402        for (i, skill) in self.skills.iter().enumerate() {
403            if i > 0 {
404                skills_json.push_str(", ");
405            }
406            skills_json.push('{');
407            skills_json.push_str(&format!("\"id\": \"{}\"", escape_json(&skill.id)));
408            let name = skill.name.as_deref().unwrap_or(&skill.id);
409            skills_json.push_str(&format!(", \"name\": \"{}\"", escape_json(name)));
410            if let Some(description) = &skill.description {
411                skills_json.push_str(&format!(", \"description\": \"{}\"", escape_json(description)));
412            }
413            if !skill.tags.is_empty() {
414                skills_json.push_str(", \"tags\": [");
415                for (j, tag) in skill.tags.iter().enumerate() {
416                    if j > 0 {
417                        skills_json.push_str(", ");
418                    }
419                    skills_json.push_str(&format!("\"{}\"", escape_json(tag)));
420                }
421                skills_json.push(']');
422            }
423            if !skill.examples.is_empty() {
424                skills_json.push_str(", \"examples\": [");
425                for (j, example) in skill.examples.iter().enumerate() {
426                    if j > 0 {
427                        skills_json.push_str(", ");
428                    }
429                    skills_json.push_str(&format!("\"{}\"", escape_json(example)));
430                }
431                skills_json.push(']');
432            }
433            skills_json.push('}');
434        }
435        skills_json.push(']');
436
437        let default_url = format!("http://localhost{}", self.path);
438        let url = self.agent_url.as_deref().unwrap_or(&default_url);
439
440        let agent_card_json = format!(
441            "{{\"name\": \"{}\", \"description\": \"{}\", \"version\": \"{}\", \"url\": \"{}\", \
442\"capabilities\": {{\"streaming\": {}, \"pushNotifications\": {}, \"stateTransitionHistory\": false}}, \
443\"skills\": {}}}",
444            escape_json(&self.agent_name),
445            escape_json(&self.agent_description),
446            escape_json(&self.agent_version),
447            escape_json(url),
448            self.streaming,
449            self.push_notification_url.is_some(),
450            skills_json,
451        );
452
453        json!({
454            "httpRequest": { "method": "GET", "path": self.agent_card_path },
455            "httpResponse": {
456                "statusCode": 200,
457                "headers": [{ "name": "Content-Type", "values": ["application/json"] }],
458                "body": agent_card_json
459            }
460        })
461    }
462
463    fn build_tasks_send(&self) -> Value {
464        let result_json = build_task_result_json(&self.default_task_response, false);
465        velocity_template_expectation(json_rpc_request(&self.path, "tasks/send"), &result_json)
466    }
467
468    fn build_tasks_get(&self) -> Value {
469        let result_json = build_task_result_json(&self.default_task_response, false);
470        velocity_template_expectation(json_rpc_request(&self.path, "tasks/get"), &result_json)
471    }
472
473    fn build_tasks_cancel(&self) -> Value {
474        let result_json = "{\"id\": \"mock-task-id\", \"status\": {\"state\": \"canceled\"}}";
475        velocity_template_expectation(json_rpc_request(&self.path, "tasks/cancel"), result_json)
476    }
477
478    fn build_streaming(&self) -> Value {
479        let text = escape_json(&self.default_task_response);
480        let task_id = "mock-task-id";
481
482        // A2A streaming: each SSE event data is a JSON-RPC 2.0 response envelope
483        // wrapping a TaskStatusUpdateEvent or TaskArtifactUpdateEvent. The
484        // JSON-RPC id is not known at build time, so a stable placeholder ("1")
485        // is used (streaming clients correlate by stream).
486        let status_working = format!(
487            "{{\"jsonrpc\": \"2.0\", \"id\": \"1\", \"result\": \
488{{\"taskId\": \"{task_id}\", \"kind\": \"status-update\", \
489\"status\": {{\"state\": \"working\"}}, \"final\": false}}}}"
490        );
491        let artifact_update = format!(
492            "{{\"jsonrpc\": \"2.0\", \"id\": \"1\", \"result\": \
493{{\"taskId\": \"{task_id}\", \"kind\": \"artifact-update\", \
494\"artifact\": {{\"parts\": [{{\"type\": \"text\", \"text\": \"{text}\"}}]}}}}}}"
495        );
496        let status_completed = format!(
497            "{{\"jsonrpc\": \"2.0\", \"id\": \"1\", \"result\": \
498{{\"taskId\": \"{task_id}\", \"kind\": \"status-update\", \
499\"status\": {{\"state\": \"completed\"}}, \"final\": true}}}}"
500        );
501
502        json!({
503            "httpRequest": json_rpc_request(&self.path, &self.streaming_method),
504            "httpSseResponse": {
505                "statusCode": 200,
506                "events": [
507                    { "event": "message", "data": status_working },
508                    { "event": "message", "data": artifact_update },
509                    { "event": "message", "data": status_completed }
510                ],
511                "closeConnection": true
512            }
513        })
514    }
515
516    fn build_push_notification_config(&self, url: &str) -> Value {
517        // Echo the registered push-notification config back as the JSON-RPC
518        // result. The url is destined for a Velocity-templated body.
519        let result_json = format!("{{\"url\": \"{}\"}}", ev_ej(url));
520        velocity_template_expectation(
521            json_rpc_request(&self.path, "tasks/pushNotificationConfig/set"),
522            &result_json,
523        )
524    }
525
526    fn build_push_notification_delivery(&self, url: &str) -> Result<Value> {
527        // When push notifications are configured, a tasks/send both returns the
528        // JSON-RPC task response to the caller AND POSTs the completed task to
529        // the configured webhook URL. Modelled with an override-forwarded
530        // request: the request override targets the webhook (literal body), and
531        // a Velocity response *template* produces the caller's JSON-RPC
532        // response so the request's id is echoed back.
533        let target = WebhookTarget::parse(url)?;
534
535        // Literal webhook POST body — no Velocity engine runs over a request
536        // override, so only JSON escaping is applied. The push payload carries
537        // no JSON-RPC id (server-initiated).
538        let push_body = format!(
539            "{{\"jsonrpc\": \"2.0\", \"result\": {}}}",
540            build_task_result_json_raw(&self.default_task_response, false)
541        );
542
543        let webhook_request = json!({
544            "method": "POST",
545            "path": target.path,
546            "secure": target.secure,
547            "socketAddress": {
548                "host": target.host,
549                "port": target.port,
550                "scheme": if target.secure { "HTTPS" } else { "HTTP" }
551            },
552            "headers": {
553                "Host": [target.host_header()],
554                "Content-Type": ["application/json"]
555            },
556            "body": push_body
557        });
558
559        // Caller response — a Velocity template so $!{request.jsonRpcRawId}
560        // echoes the request id, matching the non-push tasks/send contract.
561        let client_response_template = velocity_json_rpc_response(&build_task_result_json(
562            &self.default_task_response,
563            false,
564        ));
565
566        Ok(json!({
567            "httpRequest": json_rpc_request(&self.path, "tasks/send"),
568            "httpOverrideForwardedRequest": {
569                "requestOverride": webhook_request,
570                "responseTemplate": {
571                    "template": client_response_template,
572                    "templateType": "VELOCITY"
573                }
574            }
575        }))
576    }
577
578    fn build_custom_task_handler(&self, handler: &TaskHandler) -> Value {
579        let escaped_pattern = escape_message_pattern(&handler.message_pattern);
580        let json_path = format!(
581            "$[?(@.method == 'tasks/send' && @.params.message.parts[0].text =~ /{escaped_pattern}/)]"
582        );
583        let result_json = build_task_result_json(&handler.response_text, handler.is_error);
584        velocity_template_expectation(json_path_request(&self.path, &json_path), &result_json)
585    }
586}
587
588/// Escape a user-supplied message regular expression so it can be embedded as a
589/// `/.../`-delimited regex literal inside the JsonPath filter without allowing a
590/// breakout of the trailing `/` delimiter.
591///
592/// The pattern is documented as a regular expression, so existing escape
593/// sequences (e.g. `\d`, `\/`, `\\`) are preserved verbatim. Only delimiter
594/// breakout and control characters are neutralised:
595/// - `\` followed by another char: emitted as-is (`\` + next char), preserving
596///   the author's escape; a lone trailing `\` is doubled to `\\` so it cannot
597///   escape the closing delimiter.
598/// - bare `/`: escaped to `\/`.
599/// - newline / carriage return: escaped to `\n` / `\r`.
600/// - NUL: stripped.
601/// - any other char: emitted verbatim (UTF-8 preserved).
602fn escape_message_pattern(pattern: &str) -> String {
603    let chars: Vec<char> = pattern.chars().collect();
604    let mut out = String::with_capacity(pattern.len());
605    let mut i = 0;
606    while i < chars.len() {
607        match chars[i] {
608            '\\' => {
609                if i + 1 < chars.len() {
610                    out.push('\\');
611                    out.push(chars[i + 1]);
612                    i += 1;
613                } else {
614                    out.push_str("\\\\");
615                }
616            }
617            '/' => out.push_str("\\/"),
618            '\n' => out.push_str("\\n"),
619            '\r' => out.push_str("\\r"),
620            '\0' => {} // strip NUL
621            c => out.push(c),
622        }
623        i += 1;
624    }
625    out
626}
627
628// ---------------------------------------------------------------------------
629// Nested sub-builders
630// ---------------------------------------------------------------------------
631
632/// Sub-builder for a single A2A skill. Call [`and`](Self::and) to return to the
633/// parent [`A2aMockBuilder`].
634pub struct A2aSkillBuilder {
635    parent: A2aMockBuilder,
636    skill: SkillDef,
637}
638
639impl A2aSkillBuilder {
640    /// Set the skill display name (defaults to the skill id).
641    pub fn with_name(mut self, name: impl Into<String>) -> Self {
642        self.skill.name = Some(name.into());
643        self
644    }
645
646    /// Set the skill description.
647    pub fn with_description(mut self, description: impl Into<String>) -> Self {
648        self.skill.description = Some(description.into());
649        self
650    }
651
652    /// Add a tag to the skill.
653    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
654        self.skill.tags.push(tag.into());
655        self
656    }
657
658    /// Add an example to the skill.
659    pub fn with_example(mut self, example: impl Into<String>) -> Self {
660        self.skill.examples.push(example.into());
661        self
662    }
663
664    /// Finish this skill and return to the parent builder.
665    pub fn and(mut self) -> A2aMockBuilder {
666        self.parent.skills.push(self.skill);
667        self.parent
668    }
669}
670
671/// Sub-builder for a single custom `tasks/send` handler. Call [`and`](Self::and)
672/// to return to the parent [`A2aMockBuilder`].
673pub struct A2aTaskHandlerBuilder {
674    parent: A2aMockBuilder,
675    message_pattern: String,
676    response_text: String,
677    is_error: bool,
678}
679
680impl A2aTaskHandlerBuilder {
681    /// Set the regular expression matched against the first message part's text.
682    pub fn matching_message(mut self, pattern: impl Into<String>) -> Self {
683        self.message_pattern = pattern.into();
684        self
685    }
686
687    /// Set the response text and whether this handler models a failed task.
688    pub fn responding_with(mut self, text: impl Into<String>, is_error: bool) -> Self {
689        self.response_text = text.into();
690        self.is_error = is_error;
691        self
692    }
693
694    /// Finish this handler and return to the parent builder.
695    pub fn and(mut self) -> A2aMockBuilder {
696        self.parent.task_handlers.push(TaskHandler {
697            message_pattern: self.message_pattern,
698            response_text: self.response_text,
699            is_error: self.is_error,
700        });
701        self.parent
702    }
703}
704
705/// Create a new A2A mock builder targeting `path` for the JSON-RPC task
706/// endpoint.
707pub fn a2a_mock(path: impl Into<String>) -> A2aMockBuilder {
708    A2aMockBuilder::new(path)
709}
710
711/// Create a new A2A mock builder targeting the default `/a2a` path.
712pub fn a2a_mock_default() -> A2aMockBuilder {
713    A2aMockBuilder::new("/a2a")
714}
715
716// ---------------------------------------------------------------------------
717// Tests
718// ---------------------------------------------------------------------------
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    fn agent_card_body(expectations: &[Value]) -> String {
725        expectations[0]["httpResponse"]["body"]
726            .as_str()
727            .expect("agent card body string")
728            .to_string()
729    }
730
731    #[test]
732    fn minimal_mock_has_four_expectations() {
733        // agent card + tasks/send + tasks/get + tasks/cancel
734        let expectations = a2a_mock_default().build();
735        assert_eq!(expectations.len(), 4);
736    }
737
738    #[test]
739    fn custom_path_is_used_on_task_endpoint() {
740        let expectations = a2a_mock("/custom/a2a").build();
741        assert_eq!(expectations.len(), 4);
742        // tasks/send is expectations[1] in the minimal layout.
743        assert_eq!(expectations[1]["httpRequest"]["path"], "/custom/a2a");
744        assert_eq!(
745            expectations[1]["httpRequest"]["body"]["method"],
746            "tasks/send"
747        );
748    }
749
750    #[test]
751    fn agent_card_is_static_response() {
752        let expectations = a2a_mock_default()
753            .with_agent_name("TestAgent")
754            .with_agent_version("2.0.0")
755            .build();
756        let card = &expectations[0];
757        assert_eq!(card["httpRequest"]["method"], "GET");
758        assert_eq!(card["httpRequest"]["path"], "/.well-known/agent.json");
759        assert_eq!(card["httpResponse"]["statusCode"], 200);
760        let body = agent_card_body(&expectations);
761        assert!(body.contains("TestAgent"), "{body}");
762        assert!(body.contains("2.0.0"), "{body}");
763    }
764
765    #[test]
766    fn custom_agent_card_path() {
767        let expectations = a2a_mock_default().with_agent_card_path("/agent-card").build();
768        assert_eq!(expectations[0]["httpRequest"]["path"], "/agent-card");
769    }
770
771    #[test]
772    fn skills_appear_in_agent_card() {
773        let expectations = a2a_mock_default()
774            .with_skill("skill1")
775            .with_name("Skill One")
776            .with_description("First skill")
777            .with_tag("test")
778            .with_example("Do it")
779            .and()
780            .build();
781        // skills do not add expectations
782        assert_eq!(expectations.len(), 4);
783        let body = agent_card_body(&expectations);
784        assert!(body.contains("skill1"), "{body}");
785        assert!(body.contains("Skill One"), "{body}");
786        assert!(body.contains("First skill"), "{body}");
787        assert!(body.contains("\"tags\": [\"test\"]"), "{body}");
788        assert!(body.contains("\"examples\": [\"Do it\"]"), "{body}");
789    }
790
791    #[test]
792    fn skill_name_defaults_to_id() {
793        let expectations = a2a_mock_default().with_skill("only_id").and().build();
794        let body = agent_card_body(&expectations);
795        assert!(body.contains("\"id\": \"only_id\", \"name\": \"only_id\""), "{body}");
796    }
797
798    #[test]
799    fn task_handlers_add_two_expectations_each_before_defaults() {
800        let expectations = a2a_mock_default()
801            .on_task_send()
802            .matching_message("translate.*")
803            .responding_with("Translation: Hola", false)
804            .and()
805            .on_task_send()
806            .matching_message("summarize.*")
807            .responding_with("Summary: Brief text", false)
808            .and()
809            .build();
810        // agent card + 2 handlers + tasks/send + tasks/get + tasks/cancel
811        assert_eq!(expectations.len(), 6);
812        // custom handlers placed before the default tasks/send
813        assert!(expectations[1]["httpRequest"]["body"]["jsonPath"]
814            .as_str()
815            .unwrap()
816            .contains("translate.*"));
817        assert!(expectations[1]["httpResponseTemplate"]["template"]
818            .as_str()
819            .unwrap()
820            .contains("Translation: Hola"));
821    }
822
823    #[test]
824    fn task_responses_use_velocity_templates() {
825        let expectations = a2a_mock_default().build();
826        let tasks_send = &expectations[1];
827        assert_eq!(
828            tasks_send["httpResponseTemplate"]["templateType"],
829            "VELOCITY"
830        );
831        assert!(tasks_send["httpResponseTemplate"]["template"]
832            .as_str()
833            .unwrap()
834            .contains("$!{request.jsonRpcRawId}"));
835    }
836
837    #[test]
838    fn error_task_handler_uses_failed_state() {
839        let expectations = a2a_mock_default()
840            .on_task_send()
841            .matching_message("bad_request.*")
842            .responding_with("Error occurred", true)
843            .and()
844            .build();
845        let template = expectations[1]["httpResponseTemplate"]["template"]
846            .as_str()
847            .unwrap();
848        assert!(template.contains("failed"), "{template}");
849    }
850
851    #[test]
852    fn default_task_response_appears_in_template() {
853        let expectations = a2a_mock_default()
854            .with_default_task_response("Custom default response")
855            .build();
856        let template = expectations[1]["httpResponseTemplate"]["template"]
857            .as_str()
858            .unwrap();
859        assert!(template.contains("Custom default response"), "{template}");
860    }
861
862    #[test]
863    fn velocity_metacharacters_escaped_in_default_response() {
864        let expectations = a2a_mock_default()
865            .with_default_task_response("$100 off #sale")
866            .build();
867        let template = expectations[1]["httpResponseTemplate"]["template"]
868            .as_str()
869            .unwrap();
870        assert!(template.contains("${esc.d}100 off ${esc.h}sale"), "{template}");
871    }
872
873    #[test]
874    fn velocity_metacharacters_escaped_in_custom_handler() {
875        let expectations = a2a_mock_default()
876            .on_task_send()
877            .matching_message("test.*")
878            .responding_with("Price is $50 #discount", false)
879            .and()
880            .build();
881        let template = expectations[1]["httpResponseTemplate"]["template"]
882            .as_str()
883            .unwrap();
884        assert!(template.contains("${esc.d}50 ${esc.h}discount"), "{template}");
885    }
886
887    #[test]
888    fn slash_escaped_in_handler_message_pattern() {
889        let expectations = a2a_mock_default()
890            .on_task_send()
891            .matching_message("path/to/resource")
892            .responding_with("found", false)
893            .and()
894            .build();
895        let json_path = expectations[1]["httpRequest"]["body"]["jsonPath"]
896            .as_str()
897            .unwrap();
898        assert!(json_path.contains("path\\/to\\/resource"), "{json_path}");
899    }
900
901    #[test]
902    fn backslash_newline_and_cr_escaped_in_pattern() {
903        let expectations = a2a_mock_default()
904            .on_task_send()
905            .matching_message("line1\nline2\\d+")
906            .responding_with("found", false)
907            .and()
908            .build();
909        let json_path = expectations[1]["httpRequest"]["body"]["jsonPath"]
910            .as_str()
911            .unwrap();
912        assert!(json_path.contains("line1\\nline2\\d+"), "{json_path}");
913        assert!(!json_path.contains('\n'), "must not contain literal newline");
914    }
915
916    #[test]
917    fn null_byte_stripped_in_pattern() {
918        let expectations = a2a_mock_default()
919            .on_task_send()
920            .matching_message("before\0after")
921            .responding_with("found", false)
922            .and()
923            .build();
924        let json_path = expectations[1]["httpRequest"]["body"]["jsonPath"]
925            .as_str()
926            .unwrap();
927        assert!(json_path.contains("beforeafter"), "{json_path}");
928        assert!(!json_path.contains('\0'));
929    }
930
931    fn handler_json_path(message_pattern: &str) -> String {
932        let expectations = a2a_mock_default()
933            .on_task_send()
934            .matching_message(message_pattern)
935            .responding_with("found", false)
936            .and()
937            .build();
938        expectations[1]["httpRequest"]["body"]["jsonPath"]
939            .as_str()
940            .unwrap()
941            .to_string()
942    }
943
944    #[test]
945    fn regex_escape_sequence_is_preserved_not_doubled() {
946        // `\d+` must stay `\d+` (single backslash) — preserving the author's
947        // regex escape rather than doubling it to `\\d+`.
948        assert_eq!(escape_message_pattern(r"\d+"), r"\d+");
949        let json_path = handler_json_path(r"\d+");
950        assert!(json_path.contains(r"\d+"), "{json_path}");
951        assert!(!json_path.contains(r"\\d+"), "{json_path}");
952    }
953
954    #[test]
955    fn already_escaped_slash_is_not_double_escaped() {
956        // `a\/b` (an already-escaped slash) must stay `a\/b`, NOT become `a\\/b`.
957        assert_eq!(escape_message_pattern(r"a\/b"), r"a\/b");
958        let json_path = handler_json_path(r"a\/b");
959        assert!(json_path.contains(r"a\/b"), "{json_path}");
960        assert!(!json_path.contains(r"a\\/b"), "{json_path}");
961    }
962
963    #[test]
964    fn trailing_backslash_cannot_break_out_of_regex_delimiter() {
965        // A lone trailing backslash must be doubled so it escapes itself, NOT
966        // the closing `/` delimiter. The produced jsonPath must still terminate
967        // with an UNESCAPED `/)]`.
968        assert_eq!(escape_message_pattern(r"abc\"), r"abc\\");
969        let json_path = handler_json_path(r"abc\");
970        assert!(json_path.contains(r"abc\\/)]"), "{json_path}");
971        // Security assertion: the regex literal is properly closed — the
972        // backslash is doubled and the delimiter `/)]` remains intact.
973        assert!(json_path.ends_with(r"abc\\/)]"), "{json_path}");
974    }
975
976    #[test]
977    fn normal_slash_pattern_still_escaped_as_before() {
978        // Regression: a plain slash pattern keeps the existing behaviour.
979        assert_eq!(
980            escape_message_pattern("path/to/resource"),
981            "path\\/to\\/resource"
982        );
983        let json_path = handler_json_path("path/to/resource");
984        assert!(json_path.contains("path\\/to\\/resource"), "{json_path}");
985    }
986
987    #[test]
988    fn streaming_advertised_and_generates_sse() {
989        let expectations = a2a_mock_default()
990            .with_streaming()
991            .with_default_task_response("streamed result")
992            .build();
993        // agent card + tasks/send + streaming + tasks/get + tasks/cancel
994        assert_eq!(expectations.len(), 5);
995
996        let card = agent_card_body(&expectations);
997        assert!(card.contains("\"streaming\": true"), "{card}");
998        assert!(card.contains("\"pushNotifications\": false"), "{card}");
999
1000        let streaming = expectations
1001            .iter()
1002            .find(|e| e.get("httpSseResponse").is_some())
1003            .expect("expected an SSE expectation");
1004        let events = streaming["httpSseResponse"]["events"].as_array().unwrap();
1005        assert_eq!(events.len(), 3);
1006        assert_eq!(streaming["httpSseResponse"]["closeConnection"], true);
1007
1008        let all_data: String = events
1009            .iter()
1010            .map(|e| e["data"].as_str().unwrap())
1011            .collect();
1012        assert!(all_data.contains("status-update"));
1013        assert!(all_data.contains("artifact-update"));
1014        assert!(all_data.contains("\"state\": \"working\""));
1015        assert!(all_data.contains("\"state\": \"completed\""));
1016        assert!(all_data.contains("\"final\": true"));
1017        assert!(all_data.contains("streamed result"));
1018
1019        // streaming request matches the default streaming method
1020        assert_eq!(
1021            streaming["httpRequest"]["body"]["method"],
1022            "message/stream"
1023        );
1024    }
1025
1026    #[test]
1027    fn custom_streaming_method() {
1028        let expectations = a2a_mock_default()
1029            .with_streaming_method("tasks/sendSubscribe")
1030            .build();
1031        let streaming = expectations
1032            .iter()
1033            .find(|e| e.get("httpSseResponse").is_some())
1034            .expect("expected an SSE expectation");
1035        assert_eq!(
1036            streaming["httpRequest"]["body"]["method"],
1037            "tasks/sendSubscribe"
1038        );
1039    }
1040
1041    #[test]
1042    fn push_notifications_generate_config_and_delivery() {
1043        let expectations = a2a_mock_default()
1044            .with_push_notifications("http://localhost:1234/callback")
1045            .build();
1046        // agent card + pushConfig + tasks/send delivery + tasks/get + tasks/cancel
1047        assert_eq!(expectations.len(), 5);
1048
1049        let card = agent_card_body(&expectations);
1050        assert!(card.contains("\"pushNotifications\": true"), "{card}");
1051
1052        let mut has_config_echo = false;
1053        let mut has_forward_delivery = false;
1054        for expectation in &expectations {
1055            let method = expectation["httpRequest"]["body"]["method"]
1056                .as_str()
1057                .unwrap_or("");
1058            if method == "tasks/pushNotificationConfig/set" {
1059                has_config_echo = true;
1060            }
1061            if method == "tasks/send" && expectation.get("httpOverrideForwardedRequest").is_some() {
1062                has_forward_delivery = true;
1063                let webhook = &expectation["httpOverrideForwardedRequest"]["requestOverride"];
1064                assert_eq!(webhook["method"], "POST");
1065                assert_eq!(webhook["path"], "/callback");
1066                assert_eq!(webhook["socketAddress"]["host"], "localhost");
1067                assert_eq!(webhook["socketAddress"]["port"], 1234);
1068                assert_eq!(webhook["socketAddress"]["scheme"], "HTTP");
1069                assert_eq!(webhook["secure"], false);
1070
1071                let response_template =
1072                    &expectation["httpOverrideForwardedRequest"]["responseTemplate"];
1073                assert_eq!(response_template["templateType"], "VELOCITY");
1074                assert!(response_template["template"]
1075                    .as_str()
1076                    .unwrap()
1077                    .contains("$!{request.jsonRpcRawId}"));
1078            }
1079        }
1080        assert!(has_config_echo, "expected push-notification config echo");
1081        assert!(has_forward_delivery, "expected push-notification delivery");
1082    }
1083
1084    #[test]
1085    fn literal_webhook_push_body_not_velocity_escaped() {
1086        let expectations = a2a_mock_default()
1087            .with_push_notifications("http://localhost:1234/callback")
1088            .with_default_task_response("$100 off #sale")
1089            .build();
1090        let webhook = expectations
1091            .iter()
1092            .find_map(|e| e.get("httpOverrideForwardedRequest"))
1093            .map(|o| &o["requestOverride"])
1094            .expect("expected a webhook request override");
1095        let push_body = webhook["body"].as_str().unwrap();
1096        assert!(push_body.contains("$100 off #sale"), "{push_body}");
1097        assert!(!push_body.contains("esc.d"), "{push_body}");
1098        assert!(!push_body.contains("esc.h"), "{push_body}");
1099    }
1100
1101    #[test]
1102    fn https_webhook_default_port() {
1103        let expectations = a2a_mock_default()
1104            .with_push_notifications("https://example.com/a2a/push")
1105            .build();
1106        let webhook = expectations
1107            .iter()
1108            .find_map(|e| e.get("httpOverrideForwardedRequest"))
1109            .map(|o| &o["requestOverride"])
1110            .expect("expected a webhook request override");
1111        assert_eq!(webhook["socketAddress"]["host"], "example.com");
1112        assert_eq!(webhook["socketAddress"]["port"], 443);
1113        assert_eq!(webhook["socketAddress"]["scheme"], "HTTPS");
1114        assert_eq!(webhook["secure"], true);
1115        assert_eq!(webhook["path"], "/a2a/push");
1116    }
1117
1118    #[test]
1119    fn full_mock_layout() {
1120        let expectations = a2a_mock("/agent")
1121            .with_agent_name("FullAgent")
1122            .with_agent_description("A complete mock agent")
1123            .with_agent_version("3.0.0")
1124            .with_agent_url("http://localhost:8080/agent")
1125            .with_skill("translate")
1126            .with_name("Translation")
1127            .with_description("Translates text")
1128            .with_tag("i18n")
1129            .with_example("Translate hello to French")
1130            .and()
1131            .with_default_task_response("Default done")
1132            .on_task_send()
1133            .matching_message("translate.*")
1134            .responding_with("Bonjour", false)
1135            .and()
1136            .build();
1137        // agent card + 1 handler + tasks/send + tasks/get + tasks/cancel
1138        assert_eq!(expectations.len(), 5);
1139        let card = agent_card_body(&expectations);
1140        assert!(card.contains("FullAgent"));
1141        assert!(card.contains("http://localhost:8080/agent"));
1142        assert!(card.contains("translate"));
1143    }
1144
1145    #[test]
1146    fn agent_card_body_is_valid_json() {
1147        let expectations = a2a_mock_default()
1148            .with_agent_name("Quote\"Agent")
1149            .with_skill("s1")
1150            .with_name("Name/with\\chars")
1151            .and()
1152            .build();
1153        let body = agent_card_body(&expectations);
1154        let parsed: Value = serde_json::from_str(&body).expect("agent card must be valid JSON");
1155        assert_eq!(parsed["name"], "Quote\"Agent");
1156        assert_eq!(parsed["capabilities"]["stateTransitionHistory"], false);
1157    }
1158}