agent2agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/data/a2a.json +1961 -0
  3. data/data/a2a.proto +796 -0
  4. data/data/download-a2a-resources +4 -0
  5. data/data/spec.txt +3611 -0
  6. data/lib/a2a/agent.rb +243 -0
  7. data/lib/a2a/bindings/json_rpc.rb +110 -0
  8. data/lib/a2a/bindings/rest.rb +106 -0
  9. data/lib/a2a/client.rb +85 -0
  10. data/lib/a2a/proto.rb +444 -0
  11. data/lib/a2a/schema/definition.rb +114 -0
  12. data/lib/a2a/schema/validation_error.rb +129 -0
  13. data/lib/a2a/schema.rb +429 -0
  14. data/lib/a2a/server/cancel_task.rb +14 -0
  15. data/lib/a2a/server/create_task_push_notification_config.rb +14 -0
  16. data/lib/a2a/server/delete_task_push_notification_config.rb +14 -0
  17. data/lib/a2a/server/dispatcher.rb +127 -0
  18. data/lib/a2a/server/env.rb +28 -0
  19. data/lib/a2a/server/get_extended_agent_card.rb +15 -0
  20. data/lib/a2a/server/get_task.rb +14 -0
  21. data/lib/a2a/server/get_task_push_notification_config.rb +14 -0
  22. data/lib/a2a/server/list_task_push_notification_configs.rb +14 -0
  23. data/lib/a2a/server/list_tasks.rb +14 -0
  24. data/lib/a2a/server/send_message.rb +14 -0
  25. data/lib/a2a/server/send_streaming_message.rb +14 -0
  26. data/lib/a2a/server/subscribe_to_task.rb +14 -0
  27. data/lib/a2a/server/triage.rb +96 -0
  28. data/lib/a2a/server.rb +98 -0
  29. data/lib/a2a/sse/json_rpc_stream.rb +66 -0
  30. data/lib/a2a/sse/rest_stream.rb +50 -0
  31. data/lib/a2a/sse/stream.rb +129 -0
  32. data/lib/a2a/sse.rb +5 -0
  33. data/lib/a2a/store/processor.rb +136 -0
  34. data/lib/a2a/store/pub_sub.rb +149 -0
  35. data/lib/a2a/store/sqlite.rb +533 -0
  36. data/lib/a2a/store/webhooks.rb +94 -0
  37. data/lib/a2a/store.rb +6 -0
  38. data/lib/a2a/task_store.rb +315 -0
  39. data/lib/a2a/version.rb +5 -0
  40. data/lib/a2a.rb +26 -0
  41. metadata +216 -0
data/lib/a2a/proto.rb ADDED
@@ -0,0 +1,444 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+
6
+ module A2A
7
+ # Parses the A2A protocol's .proto file to extract service operations
8
+ # and bridge them to Schema definition classes for request/response
9
+ # validation.
10
+ #
11
+ # The .proto file is the single normative source for the A2A protocol.
12
+ # This module extracts the service definition (RPC names, request/response
13
+ # types, streaming flags, HTTP bindings) and connects each operation to
14
+ # the corresponding Schema[] class so callers can validate data:
15
+ #
16
+ # op = A2A::Proto.operation("SendMessage")
17
+ # op.request_schema.new(params).valid!
18
+ # op.response_schema.new(result).valid!
19
+ #
20
+ # A2A::Proto.operations
21
+ # #=> [Operation("SendMessage", ...), Operation("GetTask", ...), ...]
22
+ #
23
+ module Proto
24
+ PROTO_PATH = File.expand_path("../../data/a2a.proto", __dir__).freeze
25
+
26
+ # --- Data classes ---------------------------------------------------
27
+
28
+ HttpBinding = Data.define(:verb, :path, :body)
29
+
30
+ class Operation
31
+ attr_reader :name, :request_type, :response_type,
32
+ :server_streaming, :http_bindings
33
+
34
+ def initialize(name:, request_type:, response_type:,
35
+ server_streaming:, http_bindings:)
36
+ @name = name
37
+ @request_type = request_type
38
+ @response_type = response_type
39
+ @server_streaming = server_streaming
40
+ @http_bindings = http_bindings
41
+ end
42
+
43
+ def server_streaming? = @server_streaming
44
+
45
+ # Bridge to Schema: convert proto PascalCase type name to
46
+ # the JSON schema title used by A2A::Schema[].
47
+ #
48
+ # "SendMessageRequest" => Schema["Send Message Request"]
49
+ #
50
+ def request_schema
51
+ A2A::Schema[pascal_to_title(request_type)]
52
+ end
53
+
54
+ def response_schema
55
+ return nil if response_type.include?(".") # google.protobuf.Empty
56
+ A2A::Schema[pascal_to_title(response_type)]
57
+ end
58
+
59
+ # JSON-RPC and gRPC method names are identical to the RPC name.
60
+ def json_rpc_method = name
61
+ def grpc_method = name
62
+
63
+ # REST binding from the primary (non-tenant) HTTP annotation.
64
+ def rest_verb = http_bindings.first.verb
65
+ def rest_path = http_bindings.first.path
66
+
67
+ def inspect
68
+ "#<Proto::Operation #{name} #{rest_verb.upcase} #{rest_path}>"
69
+ end
70
+
71
+ private
72
+
73
+ # "SendMessageRequest" => "Send Message Request"
74
+ def pascal_to_title(str)
75
+ str.gsub(/([A-Z])/) { " #{$1}" }.strip
76
+ end
77
+ end
78
+
79
+ # --- Module API -----------------------------------------------------
80
+
81
+ class << self
82
+ def operations
83
+ @operations ||= parse_operations
84
+ end
85
+
86
+ def operation(name)
87
+ operations.find { |op| op.name == name }
88
+ end
89
+
90
+ # Reset cached state (for tests).
91
+ def reset!
92
+ @operations = nil
93
+ end
94
+
95
+ private
96
+
97
+ def parse_operations
98
+ proto_text = File.read(PROTO_PATH)
99
+ service_block = extract_service_block(proto_text)
100
+ parse_rpcs(service_block)
101
+ end
102
+
103
+ # Extract the body of `service A2AService { ... }` from the proto text.
104
+ def extract_service_block(text)
105
+ start = text.index(/^service\s+\w+\s*\{/)
106
+ return "" unless start
107
+
108
+ depth = 0
109
+ pos = text.index("{", start)
110
+ body_start = pos + 1
111
+
112
+ (pos...text.length).each do |i|
113
+ case text[i]
114
+ when "{" then depth += 1
115
+ when "}"
116
+ depth -= 1
117
+ return text[body_start...i] if depth == 0
118
+ end
119
+ end
120
+
121
+ ""
122
+ end
123
+
124
+ # Parse all `rpc` definitions from the service block text.
125
+ def parse_rpcs(block)
126
+ ops = []
127
+
128
+ rpc_chunks = block.split(/(?=^\s*rpc\s)/m).select { |c| c.match?(/^\s*rpc\s/) }
129
+
130
+ rpc_chunks.each do |chunk|
131
+ sig = chunk.match(
132
+ /rpc\s+(\w+)\s*\(\s*(\w[\w.]*)\s*\)\s*returns\s*\(\s*(stream\s+)?(\w[\w.]*)\s*\)/
133
+ )
134
+ next unless sig
135
+
136
+ name = sig[1]
137
+ request_type = sig[2]
138
+ streaming = !sig[3].nil?
139
+ response_type = sig[4]
140
+
141
+ http_bindings = parse_http_bindings(chunk)
142
+
143
+ ops << Operation.new(
144
+ name: name,
145
+ request_type: request_type,
146
+ response_type: response_type,
147
+ server_streaming: streaming,
148
+ http_bindings: http_bindings,
149
+ )
150
+ end
151
+
152
+ ops
153
+ end
154
+
155
+ def parse_http_bindings(chunk)
156
+ bindings = []
157
+
158
+ http_start = chunk.index("google.api.http")
159
+ return bindings unless http_start
160
+
161
+ eq_brace = chunk.index("{", http_start)
162
+ return bindings unless eq_brace
163
+
164
+ block_text = extract_braced_block(chunk, eq_brace)
165
+ return bindings unless block_text
166
+
167
+ primary = parse_single_binding(block_text)
168
+ bindings << primary if primary
169
+
170
+ scan_additional_bindings(block_text).each do |ab_text|
171
+ binding = parse_single_binding(ab_text)
172
+ bindings << binding if binding
173
+ end
174
+
175
+ bindings
176
+ end
177
+
178
+ def extract_braced_block(text, open_pos)
179
+ depth = 0
180
+ body_start = open_pos + 1
181
+
182
+ (open_pos...text.length).each do |i|
183
+ case text[i]
184
+ when "{" then depth += 1
185
+ when "}"
186
+ depth -= 1
187
+ return text[body_start...i] if depth == 0
188
+ end
189
+ end
190
+
191
+ nil
192
+ end
193
+
194
+ def parse_single_binding(text)
195
+ verb_match = text.match(/\b(get|post|put|patch|delete):\s*"([^"]+)"/)
196
+ return nil unless verb_match
197
+
198
+ verb = verb_match[1]
199
+ path = verb_match[2]
200
+
201
+ body_match = text.match(/\bbody:\s*"([^"]*)"/)
202
+ body = body_match ? body_match[1] : nil
203
+
204
+ HttpBinding.new(verb: verb, path: path, body: body)
205
+ end
206
+
207
+ def scan_additional_bindings(text)
208
+ blocks = []
209
+ search_from = 0
210
+
211
+ while (ab_pos = text.index("additional_bindings", search_from))
212
+ brace_pos = text.index("{", ab_pos)
213
+ break unless brace_pos
214
+
215
+ inner = extract_braced_block(text, brace_pos)
216
+ if inner
217
+ blocks << inner
218
+ search_from = brace_pos + inner.length + 2
219
+ else
220
+ break
221
+ end
222
+ end
223
+
224
+ blocks
225
+ end
226
+ end
227
+ end
228
+ end
229
+
230
+ test do
231
+ proto = A2A::Proto
232
+
233
+ it "finds 11 operations" do
234
+ proto.operations.size.should == 11
235
+ end
236
+
237
+ it "finds all operations by name" do
238
+ expected = %w[
239
+ SendMessage SendStreamingMessage GetTask ListTasks CancelTask
240
+ SubscribeToTask CreateTaskPushNotificationConfig
241
+ GetTaskPushNotificationConfig ListTaskPushNotificationConfigs
242
+ DeleteTaskPushNotificationConfig GetExtendedAgentCard
243
+ ]
244
+ proto.operations.map(&:name).sort.should == expected.sort
245
+ end
246
+
247
+ it "looks up an operation by name" do
248
+ proto.operation("SendMessage").should.not.be.nil
249
+ proto.operation("SendMessage").name.should == "SendMessage"
250
+ end
251
+
252
+ it "returns nil for unknown operation" do
253
+ proto.operation("NoSuchThing").should.be.nil
254
+ end
255
+
256
+ it "SendMessage request type" do
257
+ proto.operation("SendMessage").request_type.should == "SendMessageRequest"
258
+ end
259
+
260
+ it "SendMessage response type" do
261
+ proto.operation("SendMessage").response_type.should == "SendMessageResponse"
262
+ end
263
+
264
+ it "GetTask returns Task" do
265
+ proto.operation("GetTask").response_type.should == "Task"
266
+ end
267
+
268
+ it "CancelTask returns Task" do
269
+ proto.operation("CancelTask").response_type.should == "Task"
270
+ end
271
+
272
+ it "GetExtendedAgentCard returns AgentCard" do
273
+ proto.operation("GetExtendedAgentCard").response_type.should == "AgentCard"
274
+ end
275
+
276
+ it "DeleteTaskPushNotificationConfig returns google.protobuf.Empty" do
277
+ proto.operation("DeleteTaskPushNotificationConfig")
278
+ .response_type.should == "google.protobuf.Empty"
279
+ end
280
+
281
+ it "CreateTaskPushNotificationConfig request and response are both TaskPushNotificationConfig" do
282
+ op = proto.operation("CreateTaskPushNotificationConfig")
283
+ op.request_type.should == "TaskPushNotificationConfig"
284
+ op.response_type.should == "TaskPushNotificationConfig"
285
+ end
286
+
287
+ it "SendStreamingMessage is server-streaming" do
288
+ proto.operation("SendStreamingMessage").server_streaming?.should == true
289
+ end
290
+
291
+ it "SubscribeToTask is server-streaming" do
292
+ proto.operation("SubscribeToTask").server_streaming?.should == true
293
+ end
294
+
295
+ it "SendMessage is not server-streaming" do
296
+ proto.operation("SendMessage").server_streaming?.should == false
297
+ end
298
+
299
+ it "GetTask is not server-streaming" do
300
+ proto.operation("GetTask").server_streaming?.should == false
301
+ end
302
+
303
+ it "SendMessage has POST /message:send" do
304
+ b = proto.operation("SendMessage").http_bindings.first
305
+ b.verb.should == "post"
306
+ b.path.should == "/message:send"
307
+ b.body.should == "*"
308
+ end
309
+
310
+ it "GetTask has GET /tasks/{id=*}" do
311
+ b = proto.operation("GetTask").http_bindings.first
312
+ b.verb.should == "get"
313
+ b.path.should == "/tasks/{id=*}"
314
+ b.body.should.be.nil
315
+ end
316
+
317
+ it "ListTasks has GET /tasks" do
318
+ b = proto.operation("ListTasks").http_bindings.first
319
+ b.verb.should == "get"
320
+ b.path.should == "/tasks"
321
+ end
322
+
323
+ it "CancelTask has POST /tasks/{id=*}:cancel" do
324
+ b = proto.operation("CancelTask").http_bindings.first
325
+ b.verb.should == "post"
326
+ b.path.should == "/tasks/{id=*}:cancel"
327
+ b.body.should == "*"
328
+ end
329
+
330
+ it "DeleteTaskPushNotificationConfig has DELETE verb" do
331
+ b = proto.operation("DeleteTaskPushNotificationConfig").http_bindings.first
332
+ b.verb.should == "delete"
333
+ end
334
+
335
+ it "each operation has at least 2 HTTP bindings (primary + tenant)" do
336
+ proto.operations.each do |op|
337
+ op.http_bindings.size.should >= 2
338
+ end
339
+ end
340
+
341
+ it "second binding is the tenant-prefixed variant" do
342
+ b = proto.operation("SendMessage").http_bindings[1]
343
+ b.path.should == "/{tenant}/message:send"
344
+ b.verb.should == "post"
345
+ end
346
+
347
+ it "GetTask tenant binding" do
348
+ b = proto.operation("GetTask").http_bindings[1]
349
+ b.path.should == "/{tenant}/tasks/{id=*}"
350
+ end
351
+
352
+ it "rest_verb returns the primary verb" do
353
+ proto.operation("GetTask").rest_verb.should == "get"
354
+ proto.operation("SendMessage").rest_verb.should == "post"
355
+ proto.operation("DeleteTaskPushNotificationConfig").rest_verb.should == "delete"
356
+ end
357
+
358
+ it "rest_path returns the primary path" do
359
+ proto.operation("GetTask").rest_path.should == "/tasks/{id=*}"
360
+ proto.operation("ListTasks").rest_path.should == "/tasks"
361
+ end
362
+
363
+ it "json_rpc_method equals the operation name" do
364
+ proto.operations.each do |op|
365
+ op.json_rpc_method.should == op.name
366
+ end
367
+ end
368
+
369
+ it "grpc_method equals the operation name" do
370
+ proto.operations.each do |op|
371
+ op.grpc_method.should == op.name
372
+ end
373
+ end
374
+
375
+ it "request_schema returns the matching Schema class" do
376
+ op = proto.operation("SendMessage")
377
+ op.request_schema.should == A2A::Schema["Send Message Request"]
378
+ end
379
+
380
+ it "response_schema returns the matching Schema class" do
381
+ op = proto.operation("SendMessage")
382
+ op.response_schema.should == A2A::Schema["Send Message Response"]
383
+ end
384
+
385
+ it "response_schema for GetTask returns Task schema" do
386
+ op = proto.operation("GetTask")
387
+ op.response_schema.should == A2A::Schema["Task"]
388
+ end
389
+
390
+ it "response_schema returns nil for google.protobuf.Empty" do
391
+ op = proto.operation("DeleteTaskPushNotificationConfig")
392
+ op.response_schema.should.be.nil
393
+ end
394
+
395
+ it "every operation has a valid request_schema" do
396
+ proto.operations.each do |op|
397
+ op.request_schema.should.be.kind_of(Class)
398
+ (op.request_schema < A2A::Schema::Definition).should == true
399
+ end
400
+ end
401
+
402
+ it "every non-Empty operation has a valid response_schema" do
403
+ proto.operations.each do |op|
404
+ next if op.response_type.include?(".")
405
+ op.response_schema.should.be.kind_of(Class)
406
+ (op.response_schema < A2A::Schema::Definition).should == true
407
+ end
408
+ end
409
+
410
+ it "can build and validate a SendMessage request" do
411
+ op = proto.operation("SendMessage")
412
+ req = op.request_schema.new(
413
+ message: {
414
+ "messageId" => "msg-1",
415
+ "role" => "ROLE_USER",
416
+ "parts" => [{ "text" => "Hello" }]
417
+ }
418
+ )
419
+ req.valid?.should == true
420
+ end
421
+
422
+ it "can build and validate a GetTask request" do
423
+ op = proto.operation("GetTask")
424
+ req = op.request_schema.new(id: "task-123")
425
+ req.valid?.should == true
426
+ end
427
+
428
+ it "can build and validate a Task response" do
429
+ op = proto.operation("GetTask")
430
+ resp = op.response_schema.new(
431
+ id: "task-123",
432
+ context_id: "ctx-456",
433
+ status: { "state" => "TASK_STATE_SUBMITTED" }
434
+ )
435
+ resp.valid?.should == true
436
+ end
437
+
438
+ it "has a useful inspect" do
439
+ op = proto.operation("SendMessage")
440
+ op.inspect.should.include?("SendMessage")
441
+ op.inspect.should.include?("POST")
442
+ op.inspect.should.include?("/message:send")
443
+ end
444
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+
6
+ module A2A
7
+ module Schema
8
+ # Base class for schema-validated A2A protocol objects.
9
+ #
10
+ # Each A2A definition type (Agent Card, Agent Capabilities, Task, etc.)
11
+ # gets a dynamically-generated subclass of Definition with:
12
+ # - A JSONSchemer sub-schema attached (.schema)
13
+ # - Reader methods for each property (snake_case)
14
+ # - Validation via .valid? / .valid!
15
+ #
16
+ # caps = A2A::Schema["Agent Capabilities"].new(
17
+ # streaming: true,
18
+ # push_notifications: false
19
+ # )
20
+ # caps.valid? #=> true
21
+ # caps.streaming #=> true
22
+ # caps.push_notifications #=> false
23
+ # caps.to_h #=> { "streaming" => true, "pushNotifications" => false }
24
+ #
25
+ class Definition
26
+
27
+ def initialize(hash = {})
28
+ props = self.class.schema_properties
29
+ snake = self.class.snake_to_camel_map
30
+ @data = {}
31
+
32
+ hash.each do |key, value|
33
+ k = key.to_s
34
+
35
+ # Resolve snake_case input to camelCase storage key
36
+ camel = snake[k] || k
37
+
38
+ if props.include?(camel)
39
+ @data[camel] = value.is_a?(Definition) ? value.to_h : value
40
+ end
41
+ end
42
+ end
43
+
44
+ # --- class methods overridden by the factory -----------------------
45
+
46
+ def self.schema
47
+ raise "A2A::Schema::Definition should NOT be instantiated directly"
48
+ end
49
+
50
+ def self.definition_name
51
+ raise "A2A::Schema::Definition should NOT be instantiated directly"
52
+ end
53
+
54
+ def self.schema_properties
55
+ raise "A2A::Schema::Definition should NOT be instantiated directly"
56
+ end
57
+
58
+ def self.snake_to_camel_map
59
+ raise "A2A::Schema::Definition should NOT be instantiated directly"
60
+ end
61
+
62
+ # --- validation ----------------------------------------------------
63
+
64
+ def valid?
65
+ self.class.schema.valid?(to_h)
66
+ end
67
+
68
+ def valid!
69
+ errors = self.class.schema.validate(to_h).to_a
70
+ return true if errors.empty?
71
+
72
+ raise ValidationError.new(errors,
73
+ definition_name: self.class.definition_name,
74
+ data: to_h
75
+ )
76
+ end
77
+
78
+ # --- serialization -------------------------------------------------
79
+
80
+ # Returns the data as a plain Hash with camelCase string keys,
81
+ # matching the JSON wire format. Nested Definition instances
82
+ # are auto-coerced via deep_compact.
83
+ def to_h
84
+ deep_compact(@data)
85
+ end
86
+
87
+ def ==(other)
88
+ other.is_a?(Definition) && to_h == other.to_h
89
+ end
90
+
91
+ def inspect
92
+ "#<#{self.class.definition_name} #{to_h.inspect}>"
93
+ end
94
+
95
+ private
96
+
97
+ def deep_compact(obj)
98
+ case obj
99
+ when Hash
100
+ obj.each_with_object({}) do |(k, v), result|
101
+ compacted = deep_compact(v)
102
+ result[k] = compacted unless compacted.nil?
103
+ end
104
+ when Array
105
+ obj.map { |v| deep_compact(v) }
106
+ when Definition
107
+ obj.to_h
108
+ else
109
+ obj
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+
6
+ module A2A
7
+ module Schema
8
+ # Raised when a Definition instance fails schema validation.
9
+ #
10
+ # Collects JSONSchemer error details and formats them as a
11
+ # human-readable list with dot-notation field paths.
12
+ #
13
+ # Agent Card validation failed:
14
+ # - name is required but missing
15
+ # - capabilities.streaming must be boolean, got string
16
+ #
17
+ class ValidationError < StandardError
18
+ attr_reader :errors, :definition_name, :data
19
+
20
+ def initialize(errors, definition_name:, data: nil)
21
+ @errors = errors
22
+ @definition_name = definition_name
23
+ @data = data
24
+
25
+ super(build_message)
26
+ end
27
+
28
+ private
29
+
30
+ def build_message
31
+ lines = errors.map { |e| " - #{format_error(e)}" }
32
+ "#{definition_name} validation failed:\n#{lines.join("\n")}"
33
+ end
34
+
35
+ def format_error(error)
36
+ path = format_path(error)
37
+ type = error["type"]
38
+
39
+ case type
40
+ when "required"
41
+ missing = error.dig("details", "missing_keys")&.join(", ") || "unknown"
42
+ if path.empty?
43
+ "#{missing} is required but missing"
44
+ else
45
+ "#{path}.#{missing} is required but missing"
46
+ end
47
+ when "type"
48
+ expected = Array(error.dig("schema", "type")).join(" or ")
49
+ "#{path.empty? ? "(root)" : path} must be #{expected}"
50
+ when "enum"
51
+ allowed = error.dig("schema", "enum")&.join(", ") || "?"
52
+ "#{path.empty? ? "(root)" : path} must be one of: #{allowed}"
53
+ when "pattern"
54
+ pattern = error.dig("schema", "pattern")
55
+ "#{path.empty? ? "(root)" : path} must match pattern #{pattern}"
56
+ when "format"
57
+ fmt = error.dig("schema", "format")
58
+ "#{path.empty? ? "(root)" : path} must be a valid #{fmt}"
59
+ when "minimum", "maximum"
60
+ "#{path.empty? ? "(root)" : path} #{error["error"]}"
61
+ when "additionalProperties"
62
+ "#{path.empty? ? "(root)" : path} has unknown properties"
63
+ else
64
+ detail = error["error"] || error["type"] || "invalid"
65
+ "#{path.empty? ? "(root)" : path} #{detail}"
66
+ end
67
+ end
68
+
69
+ # Convert JSON pointer like "/properties/capabilities/streaming"
70
+ # to dot notation like "capabilities.streaming"
71
+ def format_path(error)
72
+ pointer = error["data_pointer"].to_s
73
+ return "" if pointer.empty? || pointer == "/"
74
+
75
+ pointer.delete_prefix("/").gsub("/", ".")
76
+ end
77
+ end
78
+ end
79
+ end
80
+
81
+ test do
82
+ error_data = [
83
+ {
84
+ "data_pointer" => "",
85
+ "type" => "required",
86
+ "details" => { "missing_keys" => ["name"] },
87
+ "schema" => {},
88
+ "error" => "missing keys: name"
89
+ }
90
+ ]
91
+
92
+ err = A2A::Schema::ValidationError.new(error_data, definition_name: "Agent Card")
93
+
94
+ it "includes the definition name" do
95
+ err.message.should.include?("Agent Card")
96
+ end
97
+
98
+ it "formats required errors" do
99
+ err.message.should.include?("name is required but missing")
100
+ end
101
+
102
+ it "stores the errors array" do
103
+ err.errors.should == error_data
104
+ end
105
+
106
+ it "stores the definition name" do
107
+ err.definition_name.should == "Agent Card"
108
+ end
109
+
110
+ nested = A2A::Schema::ValidationError.new(
111
+ [{ "data_pointer" => "/capabilities/streaming", "type" => "type",
112
+ "schema" => { "type" => "boolean" }, "error" => "wrong type" }],
113
+ definition_name: "Agent Card"
114
+ )
115
+
116
+ it "formats nested paths with dot notation" do
117
+ nested.message.should.include?("capabilities.streaming must be boolean")
118
+ end
119
+
120
+ additional = A2A::Schema::ValidationError.new(
121
+ [{ "data_pointer" => "/foo", "type" => "additionalProperties",
122
+ "schema" => {}, "error" => "unexpected" }],
123
+ definition_name: "Test"
124
+ )
125
+
126
+ it "formats additionalProperties errors" do
127
+ additional.message.should.include?("foo has unknown properties")
128
+ end
129
+ end