car-a2a 0.22.0

Bridge between Common Agent Runtime and the Linux Foundation Agent2Agent (A2A) v1.0 protocol
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
//! A2A v1.0 protocol types.
//!
//! Wire field names follow the published spec at
//! <https://a2a-protocol.org/latest/specification/>. Rust identifiers stay
//! `snake_case`; `#[serde(rename_all = "camelCase")]` handles the
//! translation. Optional fields use `Option<T>` with `default` so peer
//! payloads that omit them parse cleanly.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;

// --- Agent Card -------------------------------------------------------------

/// A2A Agent Card. Served at `/.well-known/agent-card.json` by an
/// A2A agent so peers can discover capabilities, skills, and security
/// schemes before sending their first task.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentCard {
    pub name: String,
    pub description: String,
    /// Main URL where the agent is reachable. The transport at this
    /// URL is `preferred_transport`.
    pub url: String,
    /// The agent's product version (not the protocol version — that
    /// goes in `protocol_version`).
    pub version: String,
    /// A2A protocol version the agent speaks.
    pub protocol_version: String,
    /// Transport at the main `url`. Defaults to "JSONRPC".
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub preferred_transport: Option<String>,
    /// Optional URL to additional documentation about the agent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub documentation_url: Option<String>,
    /// Optional URL to an icon image for UI surfaces.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub icon_url: Option<String>,
    pub provider: AgentProvider,
    pub capabilities: AgentCapabilities,
    pub default_input_modes: Vec<String>,
    pub default_output_modes: Vec<String>,
    pub skills: Vec<AgentSkill>,
    /// v1.0 canonical interface list. REQUIRED in v1.0; first entry
    /// is the preferred binding. The bridge populates this from the
    /// embedder's bind URL + protocol so v1.0-strict peers find
    /// reachability data here.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub supported_interfaces: Vec<AgentInterface>,
    /// v0.3 alias of `supported_interfaces` (without the first /
    /// preferred entry, by convention). Kept on emission so v0.3
    /// peers reading the older field name still find transports.
    /// New code sets `supported_interfaces`; this is derived for
    /// backward compat.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub additional_interfaces: Vec<AgentInterface>,
    /// Available security schemes the agent advertises (by name).
    /// `security_requirements` (below) lists which combinations are
    /// *required* per request.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub security_schemes: HashMap<String, SecurityScheme>,
    /// Per-request security requirements: a list of `SecurityRequirement`
    /// objects per A2A v1.0. The outer list is alternatives (OR);
    /// each `SecurityRequirement.schemes` map names the schemes that
    /// must all be present (AND). Empty list = no auth required.
    ///
    /// Wire form: `[{"schemes": {"oauth": {"list": ["read"]}}},
    ///              {"schemes": {"api-key": {"list": []},
    ///                           "mtls": {"list": []}}}]`
    /// — "OAuth(read) OR (api-key AND mtls)."
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub security_requirements: Vec<SecurityRequirement>,
    /// True if the agent serves an extended card via
    /// `GetExtendedAgentCard` (v1.0) /
    /// `agent/getAuthenticatedExtendedCard` (v0.3) once authenticated.
    #[serde(default)]
    pub supports_authenticated_extended_card: bool,
    /// JWS signatures computed for this `AgentCard` per RFC 7515.
    /// Populated by [`crate::signing::sign_agent_card`] when the
    /// embedder wants peers to verify provenance / domain. Empty
    /// vector when the card is unsigned.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub signatures: Vec<AgentCardSignature>,
}

/// One JWS signature attached to an `AgentCard`. Follows the JSON
/// serialization of RFC 7515 in detached form — the AgentCard JSON
/// itself is the payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentCardSignature {
    /// The protected JWS header. Base64url-encoded JSON object.
    pub protected: String,
    /// The computed signature, base64url-encoded.
    pub signature: String,
    /// Optional unprotected JWS header values.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub header: Option<Value>,
}

impl Default for AgentCard {
    /// Test-friendly defaults. Fills in every required field with a
    /// placeholder so `AgentCard { name: "...", url: "...", ..Default::default() }`
    /// works for tests and embedders that want to mutate just a few
    /// fields. Not meant for production cards — for those, use
    /// [`crate::card::build_default_agent_card`] which derives skills
    /// from a live runtime.
    fn default() -> Self {
        Self {
            name: String::new(),
            description: String::new(),
            url: String::new(),
            version: "1.0.0".into(),
            protocol_version: "1.0".into(),
            preferred_transport: Some("JSONRPC".into()),
            documentation_url: None,
            icon_url: None,
            provider: AgentProvider {
                organization: String::new(),
                url: None,
            },
            capabilities: AgentCapabilities::default(),
            default_input_modes: Vec::new(),
            default_output_modes: Vec::new(),
            skills: Vec::new(),
            supported_interfaces: Vec::new(),
            additional_interfaces: Vec::new(),
            security_schemes: HashMap::new(),
            security_requirements: Vec::new(),
            supports_authenticated_extended_card: false,
            signatures: Vec::new(),
        }
    }
}

/// A2A v1.0 `SecurityRequirement` — a single AND-group of schemes
/// that must all be present on a request. The outer `Vec` on
/// `AgentCard.security_requirements` is the OR alternatives.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecurityRequirement {
    /// Map of scheme name → required scopes (wrapped in `StringList`
    /// per the proto's `map<string, StringList>` definition).
    #[serde(default)]
    pub schemes: HashMap<String, StringList>,
}

/// A2A v1.0 `StringList` — proto wrapper around a list of strings,
/// used as the value type in `SecurityRequirement.schemes` so the
/// JSON wire form is `{"list": [...]}` rather than a bare array.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StringList {
    #[serde(default)]
    pub list: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentProvider {
    pub organization: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentCapabilities {
    pub streaming: bool,
    pub push_notifications: bool,
    /// v0.3 holdover. v1.0 has no equivalent on `AgentCapabilities`;
    /// kept here for backward-compat emission.
    pub state_transition_history: bool,
    /// v1.0: indicates the agent serves an extended card via
    /// `GetExtendedAgentCard` once authenticated. The bridge mirrors
    /// this onto the v0.3 top-level `supportsAuthenticatedExtendedCard`
    /// for cross-version compat.
    #[serde(default)]
    pub extended_agent_card: bool,
    /// v1.0: protocol extensions the agent advertises. Empty by
    /// default. Modeled as `Value` to keep the wire form open while
    /// the spec settles.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub extensions: Vec<Value>,
}

/// One advertised skill on the agent card. CAR maps each registered
/// tool to a skill so peer agents can see what operations are
/// available.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentSkill {
    pub id: String,
    pub name: String,
    pub description: String,
    pub tags: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub examples: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub input_modes: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub output_modes: Vec<String>,
}

/// One supported transport interface an agent serves.
///
/// v1.0 fields per `a2a.proto::AgentInterface`:
/// - `url` — required absolute URL where this binding listens.
/// - `protocol_binding` — open string (`JSONRPC`, `GRPC`,
///   `HTTP+JSON`, or any future extension). Required.
/// - `tenant` — optional tenant id for multi-tenant deployments.
/// - `protocol_version` — required version string (`"0.3"`, `"1.0"`).
///
/// The bridge also serves a v0.3 alias `transport: TransportProtocol`
/// that mirrors `protocol_binding` for peers reading the older
/// schema. New embedders set `protocol_binding` directly and ignore
/// the alias.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentInterface {
    pub url: String,
    /// v1.0 wire field. Open string. The `TransportProtocol` constants
    /// (`"JSONRPC"`, `"GRPC"`, `"HTTP+JSON"`) are convenience values.
    #[serde(default = "default_protocol_binding")]
    pub protocol_binding: String,
    /// v0.3 alias of `protocol_binding`. Emitted alongside on the
    /// wire so v0.3-only peers can still read the transport. Strict
    /// v1.0 readers ignore this field.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub transport: Option<TransportProtocol>,
    /// Optional tenant id (multi-tenant deployments). v1.0 only.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tenant: Option<String>,
    /// A2A protocol version this interface speaks. Required in v1.0.
    /// Defaults to `"1.0"` when omitted.
    #[serde(default = "default_interface_protocol_version")]
    pub protocol_version: String,
}

fn default_protocol_binding() -> String {
    "JSONRPC".to_string()
}

fn default_interface_protocol_version() -> String {
    "1.0".to_string()
}

/// v0.3-style transport enum — kept for ergonomics. v1.0 uses
/// `AgentInterface.protocol_binding: String` (open). New code should
/// prefer the string field; this enum exists to ease migration.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum TransportProtocol {
    #[serde(rename = "JSONRPC")]
    JsonRpc,
    #[serde(rename = "GRPC")]
    Grpc,
    #[serde(rename = "HTTP+JSON")]
    HttpJson,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum SecurityScheme {
    #[serde(rename = "apiKey")]
    ApiKey {
        #[serde(rename = "in")]
        location: String,
        name: String,
    },
    #[serde(rename = "http")]
    Http { scheme: String },
    #[serde(rename = "oauth2")]
    OAuth2 { flows: Value },
    #[serde(rename = "openIdConnect")]
    OpenIdConnect {
        #[serde(rename = "openIdConnectUrl")]
        open_id_connect_url: String,
    },
    #[serde(rename = "mutualTLS")]
    MutualTls,
}

// --- Task / Message / Part / Artifact --------------------------------------

/// A2A Task lifecycle state.
///
/// Wire form is lowercase / kebab-case per the v1.0 JSON-RPC binding
/// (`"submitted"`, `"input-required"`, etc.). The protobuf definition
/// uses `TASK_STATE_*` enum names — that surface is gRPC-only and
/// must not appear on JSON wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TaskState {
    Submitted,
    Working,
    InputRequired,
    AuthRequired,
    Completed,
    Failed,
    Canceled,
    Rejected,
}

impl TaskState {
    pub fn is_terminal(self) -> bool {
        matches!(
            self,
            TaskState::Completed | TaskState::Failed | TaskState::Canceled | TaskState::Rejected
        )
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Task {
    pub id: String,
    pub context_id: String,
    pub status: TaskStatus,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub history: Vec<Message>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub artifacts: Vec<Artifact>,
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub metadata: HashMap<String, Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskStatus {
    pub state: TaskState,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub message: Option<Message>,
    pub timestamp: DateTime<Utc>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessageRole {
    User,
    Agent,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Message {
    pub message_id: String,
    pub role: MessageRole,
    pub parts: Vec<Part>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub task_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub context_id: Option<String>,
    /// Free-form key/value map per A2A v1.0 §5.4. CAR honors two
    /// well-known keys for multi-turn correlation (Parslee-ai/car#179):
    ///
    /// - `correlationId` — pair a follow-up reply with the original
    ///   request when the peer doesn't issue a `taskId` (telemetry →
    ///   improvement-patch loops, where neither side wants the task-
    ///   lifecycle ceremony).
    /// - `parentMessageId` — `message_id` of the message this one is
    ///   replying to. Threading without task scope.
    ///
    /// Both keys are advisory: bridges (FFI observers, push
    /// dispatchers) round-trip them through `metadata` unchanged but
    /// don't enforce semantics. Peers that ignore them stay
    /// spec-conformant. Implementations that honor them MUST surface
    /// the field name verbatim — no snake_case alias.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub metadata: HashMap<String, Value>,
}

/// A message or artifact "part". The wire discriminator is `kind`,
/// per the A2A v1.0 schema (not `type`, which would collide with
/// security-scheme tagging).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Part {
    #[serde(rename = "text")]
    Text(TextPart),
    #[serde(rename = "file")]
    File(FilePart),
    #[serde(rename = "data")]
    Data(DataPart),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextPart {
    pub text: String,
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub metadata: HashMap<String, Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FilePart {
    pub file: FileContent,
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub metadata: HashMap<String, Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileContent {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mime_type: Option<String>,
    /// Inline base64-encoded bytes. Mutually exclusive with `uri` per
    /// the spec — we do not enforce that here; consumers should
    /// validate.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bytes: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub uri: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DataPart {
    pub data: Value,
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub metadata: HashMap<String, Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Artifact {
    pub artifact_id: String,
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    pub parts: Vec<Part>,
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub metadata: HashMap<String, Value>,
}

// --- RPC method params / results -------------------------------------------

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SendMessageParams {
    pub message: Message,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub configuration: Option<MessageConfiguration>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MessageConfiguration {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub accepted_output_modes: Vec<String>,
    #[serde(default)]
    pub blocking: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub history_length: Option<u32>,
}

/// `message/send` returns either a Task (when the agent created one)
/// or a direct Message reply (for trivial synchronous replies).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SendMessageResult {
    Task(Task),
    Message(Message),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetTaskParams {
    pub id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub history_length: Option<u32>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListTasksParams {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub context_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub state: Option<TaskState>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub limit: Option<u32>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListTasksResult {
    pub tasks: Vec<Task>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PushNotificationConfig {
    pub url: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub token: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub authentication: Option<Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PushNotificationConfigParams {
    pub task_id: String,
    pub config: PushNotificationConfig,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn task_state_serializes_kebab_case() {
        assert_eq!(
            serde_json::to_string(&TaskState::InputRequired).unwrap(),
            "\"input-required\""
        );
        assert_eq!(
            serde_json::to_string(&TaskState::Submitted).unwrap(),
            "\"submitted\""
        );
        assert_eq!(
            serde_json::to_string(&TaskState::AuthRequired).unwrap(),
            "\"auth-required\""
        );
    }

    #[test]
    fn part_uses_kind_discriminator() {
        let part = Part::Text(TextPart {
            text: "hi".into(),
            metadata: HashMap::new(),
        });
        let v = serde_json::to_value(&part).unwrap();
        assert_eq!(v["kind"], "text");
        assert_eq!(v["text"], "hi");
    }

    #[test]
    fn message_round_trips_camel_case() {
        let msg = Message {
            message_id: "m-1".into(),
            role: MessageRole::User,
            parts: vec![Part::Text(TextPart {
                text: "hello".into(),
                metadata: HashMap::new(),
            })],
            task_id: Some("t-1".into()),
            context_id: None,
            metadata: HashMap::new(),
        };
        let v = serde_json::to_value(&msg).unwrap();
        assert_eq!(v["messageId"], "m-1");
        assert_eq!(v["taskId"], "t-1");
        let parsed: Message = serde_json::from_value(v).unwrap();
        assert_eq!(parsed.message_id, "m-1");
        assert_eq!(parsed.role, MessageRole::User);
    }

    #[test]
    fn agent_card_emits_camel_case() {
        let card = AgentCard {
            name: "CAR".into(),
            description: "test".into(),
            url: "https://example.com".into(),
            version: "1.0.0".into(),
            protocol_version: "1.0".into(),
            preferred_transport: Some("JSONRPC".into()),
            provider: AgentProvider {
                organization: "Parslee".into(),
                url: None,
            },
            capabilities: AgentCapabilities {
                streaming: true,
                push_notifications: false,
                state_transition_history: true,
                extended_agent_card: false,
                extensions: Vec::new(),
            },
            default_input_modes: vec!["text".into()],
            default_output_modes: vec!["text".into()],
            skills: vec![],
            documentation_url: None,
            icon_url: None,
            supported_interfaces: vec![],
            additional_interfaces: vec![AgentInterface {
                url: "wss://example.com".into(),
                protocol_binding: "JSONRPC".into(),
                transport: Some(TransportProtocol::JsonRpc),
                tenant: None,
                protocol_version: "1.0".into(),
            }],
            security_schemes: HashMap::new(),
            supports_authenticated_extended_card: false,
            security_requirements: vec![],
            signatures: vec![],
        };
        let v = serde_json::to_value(&card).unwrap();
        assert_eq!(v["defaultInputModes"][0], "text");
        assert_eq!(v["capabilities"]["pushNotifications"], false);
        assert_eq!(v["additionalInterfaces"][0]["transport"], "JSONRPC");
    }
}