agent2agent 1.0.8 → 1.1.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/lib/a2a/agent.rb +165 -117
  3. data/lib/a2a/client.rb +470 -51
  4. data/lib/a2a/errors/json_rpc_error.rb +71 -0
  5. data/lib/a2a/errors/rest_error.rb +68 -0
  6. data/lib/a2a/errors.rb +535 -0
  7. data/lib/a2a/faraday/middleware/json_rpc/request.rb +96 -0
  8. data/lib/a2a/faraday/middleware/json_rpc/response.rb +131 -0
  9. data/lib/a2a/faraday/middleware/rest/request.rb +166 -0
  10. data/lib/a2a/faraday/middleware/rest/response.rb +144 -0
  11. data/lib/a2a/faraday/middleware/schema_request.rb +69 -0
  12. data/lib/a2a/middleware/extract_message.rb +120 -0
  13. data/lib/a2a/middleware/fetch_task.rb +228 -0
  14. data/lib/a2a/middleware/limit_history_length.rb +123 -0
  15. data/lib/a2a/middleware/limit_pagination_size.rb +133 -0
  16. data/lib/a2a/middleware/sse_stream.rb +235 -0
  17. data/lib/a2a/middleware.rb +7 -0
  18. data/lib/a2a/schema/definition.rb +35 -1
  19. data/lib/a2a/schema.rb +126 -0
  20. data/lib/a2a/{bindings → server/bindings}/json_rpc.rb +12 -8
  21. data/lib/a2a/{bindings → server/bindings}/rest.rb +12 -8
  22. data/lib/a2a/server/dispatcher.rb +52 -54
  23. data/lib/a2a/server/env.rb +4 -6
  24. data/lib/a2a/server/triage.rb +1 -1
  25. data/lib/a2a/server.rb +10 -10
  26. data/lib/a2a/sse/event_parser.rb +202 -0
  27. data/lib/a2a/sse/json_rpc_stream.rb +27 -5
  28. data/lib/a2a/sse/rest_stream.rb +17 -5
  29. data/lib/a2a/sse/stream.rb +135 -7
  30. data/lib/a2a/sse.rb +1 -0
  31. data/lib/a2a/test_helpers.rb +89 -0
  32. data/lib/a2a/version.rb +1 -1
  33. data/lib/a2a.rb +6 -2
  34. data/lib/traces/provider/a2a/{bindings → server/bindings}/json_rpc.rb +2 -2
  35. data/lib/traces/provider/a2a/{bindings → server/bindings}/rest.rb +2 -2
  36. data/lib/traces/provider/a2a.rb +2 -2
  37. metadata +49 -22
  38. data/lib/a2a/server/cancel_task.rb +0 -14
  39. data/lib/a2a/server/create_task_push_notification_config.rb +0 -14
  40. data/lib/a2a/server/delete_task_push_notification_config.rb +0 -14
  41. data/lib/a2a/server/get_extended_agent_card.rb +0 -15
  42. data/lib/a2a/server/get_task.rb +0 -14
  43. data/lib/a2a/server/get_task_push_notification_config.rb +0 -14
  44. data/lib/a2a/server/list_task_push_notification_configs.rb +0 -14
  45. data/lib/a2a/server/list_tasks.rb +0 -14
  46. data/lib/a2a/server/send_message.rb +0 -14
  47. data/lib/a2a/server/send_streaming_message.rb +0 -14
  48. data/lib/a2a/server/subscribe_to_task.rb +0 -14
  49. data/lib/a2a/store/processor.rb +0 -136
  50. data/lib/a2a/store/pub_sub.rb +0 -149
  51. data/lib/a2a/store/sqlite.rb +0 -533
  52. data/lib/a2a/store/webhooks.rb +0 -94
  53. data/lib/a2a/store.rb +0 -6
  54. data/lib/a2a/task_store.rb +0 -315
data/lib/a2a/client.rb CHANGED
@@ -2,89 +2,508 @@
2
2
 
3
3
  require "bundler/setup"
4
4
  require "a2a"
5
+ require "faraday"
6
+ require "async/http/faraday"
5
7
  require "console"
6
8
 
7
9
  module A2A
8
- # Async-HTTP based A2A protocol client.
10
+ # Faraday-based A2A protocol client.
9
11
  #
10
- # Discovers agent cards and invokes operations via JSON-RPC:
12
+ # Supports both protocol bindings: JSON-RPC 2.0 and HTTP+JSON/REST.
13
+ # Uses the async-http-faraday adapter for fiber-based non-blocking I/O
14
+ # and supports SSE streaming for long-lived connections.
11
15
  #
12
- # Async do
13
- # client = A2A::Client.new("http://localhost:9292")
14
- # card = client.agent_card
15
- # result = client.send_message(message: { ... })
16
- # task = client.get_task(id: "task-123")
17
- # end
16
+ # Request params are validated against the operation's request schema
17
+ # before sending. Responses are returned as Schema::Definition objects.
18
+ #
19
+ # # JSON-RPC (default)
20
+ # client = A2A::Client.new("http://localhost:9292")
21
+ #
22
+ # # REST
23
+ # client = A2A::Client.new("http://localhost:9292", binding: :rest)
18
24
  #
19
25
  class Client
20
- def initialize(url)
21
- @url = url.chomp("/")
26
+ def initialize(url, binding: :json_rpc, &block)
27
+ @url = url.chomp("/")
28
+ @binding = binding
29
+ @conn = build_connection(&block)
22
30
  end
23
31
 
24
32
  # GET /.well-known/agent-card.json
33
+ #
34
+ # Returns an A2A::Schema["Agent Card"] instance.
25
35
  def agent_card
26
- get("/.well-known/agent-card.json")
36
+ response = @conn.get("/.well-known/agent-card.json")
37
+ parsed = response.body
38
+ A2A::Schema["Agent Card"].new(parsed)
27
39
  end
28
40
 
29
- # JSON-RPC operations — each maps to a Proto operation name.
41
+ # Operations — each maps to a Proto operation name.
30
42
  Proto.operations.each do |op|
31
43
  method_name = op.name.gsub(/([A-Z])/) { "_#{$1.downcase}" }.sub(/^_/, "")
32
44
 
33
- define_method(method_name) do |params = {}|
34
- Console.info(self) { "Client #{op.name}: #{params}" }
35
- json_rpc(op.name, params)
45
+ if op.server_streaming?
46
+ define_method(method_name) do |params = {}, &block|
47
+ Console.info(self) { "Client #{op.name}: #{params}" }
48
+ invoke_streaming(op, params, &block)
49
+ end
50
+ else
51
+ define_method(method_name) do |params = {}|
52
+ Console.info(self) { "Client #{op.name}: #{params}" }
53
+ invoke(op, params)
54
+ end
36
55
  end
37
56
  end
38
57
 
39
58
  private
40
59
 
41
- def json_rpc(method, params)
42
- body = JSON.generate(
43
- jsonrpc: "2.0",
44
- id: next_id,
45
- method: method,
46
- params: params,
47
- )
60
+ def build_connection(&block)
61
+ case @binding
62
+ when :json_rpc then build_json_rpc_connection(&block)
63
+ when :rest then build_rest_connection(&block)
64
+ else raise ArgumentError, "Unknown binding: #{@binding}"
65
+ end
66
+ end
67
+
68
+ def build_json_rpc_connection(&block)
69
+ ::Faraday.new(url: @url) do |f|
70
+ f.request :a2a_schema
71
+ f.request :a2a_json_rpc
72
+ f.request :json
73
+
74
+ f.response :a2a_json_rpc
75
+ f.response :json
48
76
 
49
- response = post("/a2a", body, "application/json")
50
- parsed = JSON.parse(response)
77
+ f.adapter :async_http
51
78
 
52
- Console.info(self) { "Client result: #{parsed}" }
79
+ block&.call(f)
80
+ end
81
+ end
82
+
83
+ def build_rest_connection(&block)
84
+ ::Faraday.new(url: @url) do |f|
85
+ f.request :a2a_schema
86
+ f.request :a2a_rest
87
+ f.request :json
88
+
89
+ f.response :a2a_rest
90
+ f.response :json
53
91
 
54
- if (error = parsed["error"])
55
- raise "JSON-RPC error #{error["code"]}: #{error["message"]}"
92
+ f.adapter :async_http
93
+
94
+ block&.call(f)
56
95
  end
96
+ end
97
+
98
+ def invoke(operation, params)
99
+ request = operation.request_schema.new(params)
100
+ request.valid!
101
+
102
+ response = @conn.post("/") do |req|
103
+ req.options.context = { a2a_operation: operation }
104
+ req.body = request
105
+ end
106
+
107
+ response.body
108
+ end
109
+
110
+ def invoke_streaming(operation, params, &block)
111
+ request = operation.request_schema.new(params)
112
+ request.valid!
113
+
114
+ parser = A2A::SSE::EventParser.new(binding: @binding)
115
+
116
+ @conn.post("/") do |req|
117
+ req.options.context = { a2a_operation: operation }
118
+ req.body = request
119
+ req.headers["Accept"] = "text/event-stream"
120
+
121
+ if block
122
+ req.options.on_data = proc do |chunk, _size, _env|
123
+ parser.feed(chunk) { |event| block.call(event) }
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+
131
+ test do
132
+ # --- JSON-RPC binding (default) ---
133
+
134
+ it "generates methods for all Proto operations" do
135
+ client = A2A::Client.new("http://localhost:9292") do |f|
136
+ f.adapter :test do |stub|
137
+ stub.post("/a2a") { |env|
138
+ [200, { "content-type" => "application/json" }, JSON.generate({ "jsonrpc" => "2.0", "id" => 1, "result" => {} })]
139
+ }
140
+ end
141
+ end
57
142
 
58
- parsed["result"]
143
+ expected = %w[
144
+ send_message
145
+ send_streaming_message
146
+ get_task
147
+ list_tasks
148
+ cancel_task
149
+ subscribe_to_task
150
+ create_task_push_notification_config
151
+ get_task_push_notification_config
152
+ list_task_push_notification_configs
153
+ delete_task_push_notification_config
154
+ get_extended_agent_card
155
+ ]
156
+
157
+ expected.each do |name|
158
+ client.respond_to?(name).should == true
159
+ end
160
+ end
161
+
162
+ it "agent_card returns a Schema object" do
163
+ client = A2A::Client.new("http://localhost:9292") do |f|
164
+ f.adapter :test do |stub|
165
+ stub.get("/.well-known/agent-card.json") { |env|
166
+ [200, { "content-type" => "application/json" }, JSON.generate({
167
+ "name" => "Test Agent",
168
+ "version" => "1.0.0",
169
+ "capabilities" => { "streaming" => true }
170
+ })]
171
+ }
59
172
  end
173
+ end
174
+
175
+ card = client.agent_card
176
+ card.should.be.kind_of(A2A::Schema::Definition)
177
+ card.name.should == "Test Agent"
178
+ card.version.should == "1.0.0"
179
+ end
180
+
181
+ it "json_rpc: send_message validates, wraps in JSON-RPC, returns Schema" do
182
+ client = A2A::Client.new("http://localhost:9292") do |f|
183
+ f.adapter :test do |stub|
184
+ stub.post("/a2a") { |env|
185
+ parsed = JSON.parse(env.body)
186
+ parsed["method"].should == "SendMessage"
187
+ parsed["params"]["message"]["role"].should == "ROLE_USER"
60
188
 
61
- def get(path)
62
- Async do
63
- internet = Async::HTTP::Internet.new
64
- response = internet.get("#{@url}#{path}")
65
- body = response.read
66
- internet.close
67
- JSON.parse(body)
68
- end.wait
189
+ [200, { "content-type" => "application/json" }, JSON.generate({
190
+ "jsonrpc" => "2.0", "id" => parsed["id"],
191
+ "result" => {
192
+ "task" => {
193
+ "id" => "task-1",
194
+ "contextId" => "ctx-1",
195
+ "status" => { "state" => "TASK_STATE_COMPLETED" },
196
+ "artifacts" => [{
197
+ "artifactId" => "a-1",
198
+ "parts" => [{ "text" => "Echo: Hello" }]
199
+ }]
200
+ }
201
+ }
202
+ })]
203
+ }
69
204
  end
205
+ end
206
+
207
+ result = client.send_message(
208
+ message: {
209
+ message_id: "msg-1",
210
+ role: "ROLE_USER",
211
+ parts: [{ text: "Hello" }]
212
+ }
213
+ )
214
+ result.should.be.kind_of(A2A::Schema::Definition)
215
+ end
216
+
217
+ it "json_rpc: get_task returns a Task" do
218
+ client = A2A::Client.new("http://localhost:9292") do |f|
219
+ f.adapter :test do |stub|
220
+ stub.post("/a2a") { |env|
221
+ parsed = JSON.parse(env.body)
222
+ parsed["method"].should == "GetTask"
223
+ parsed["params"]["id"].should == "task-123"
70
224
 
71
- def post(path, body, content_type)
72
- Async do
73
- internet = Async::HTTP::Internet.new
74
- response = internet.post(
75
- "#{@url}#{path}",
76
- [["content-type", content_type]],
77
- [body],
78
- )
79
- result = response.read
80
- internet.close
81
- Console.info(self) { "Agent responded: #{result}" }
82
- result
83
- end.wait
225
+ [200, { "content-type" => "application/json" }, JSON.generate({
226
+ "jsonrpc" => "2.0", "id" => parsed["id"],
227
+ "result" => {
228
+ "id" => "task-123",
229
+ "contextId" => "ctx-456",
230
+ "status" => {
231
+ "state" => "TASK_STATE_SUBMITTED"
232
+ }
233
+ }
234
+ })]
235
+ }
84
236
  end
237
+ end
238
+
239
+ result = client.get_task(id: "task-123")
240
+ result.should.be.kind_of(A2A::Schema::Definition)
241
+ result.id.should == "task-123"
242
+ result.context_id.should == "ctx-456"
243
+ end
85
244
 
86
- def next_id
87
- @id_counter = (@id_counter || 0) + 1
245
+ it "json_rpc: raises on JSON-RPC error" do
246
+ client = A2A::Client.new("http://localhost:9292") do |f|
247
+ f.adapter :test do |stub|
248
+ stub.post("/a2a") { |env|
249
+ [200, { "content-type" => "application/json" }, JSON.generate({
250
+ "jsonrpc" => "2.0",
251
+ "id" => 1,
252
+ "error" => {
253
+ "code" => -32600,
254
+ "message" => "Invalid Request"
255
+ }
256
+ })]
257
+ }
88
258
  end
259
+ end
260
+
261
+ lambda { client.get_task(id: "task-123") }.should.raise(A2A::JsonRpcError)
262
+ end
263
+
264
+ it "json_rpc: raises ValidationError on invalid params" do
265
+ client = A2A::Client.new("http://localhost:9292") do |f|
266
+ f.adapter :test do |stub|
267
+ stub.post("/a2a") { |env| [200, { "content-type" => "application/json" }, "{}"] }
268
+ end
269
+ end
270
+
271
+ lambda { client.send_message(message: "not_a_hash") }.should.raise(A2A::Schema::ValidationError)
272
+ end
273
+
274
+ it "json_rpc: send_streaming_message sends correct method and Accept header" do
275
+ captured_env = nil
276
+ client = A2A::Client.new("http://localhost:9292") do |f|
277
+ f.adapter :test do |stub|
278
+ stub.post("/a2a") { |env|
279
+ captured_env = env
280
+ [200, { "content-type" => "text/event-stream" }, ""]
281
+ }
282
+ end
283
+ end
284
+
285
+ client.send_streaming_message(
286
+ message: {
287
+ message_id: "msg-1",
288
+ role: "ROLE_USER",
289
+ parts: [{ text: "Hello" }]
290
+ }
291
+ ) do |event|
292
+ end
293
+
294
+ parsed = JSON.parse(captured_env.request_body)
295
+ parsed["method"].should == "SendStreamingMessage"
296
+ captured_env.request_headers["Accept"].should == "text/event-stream"
297
+ end
298
+
299
+ # --- REST binding ---
300
+
301
+ it "rest: send_message posts to /message:send with application/a2a+json" do
302
+ captured_env = nil
303
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
304
+ f.adapter :test do |stub|
305
+ stub.post("/message:send") { |env|
306
+ captured_env = env
307
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({
308
+ "task" => {
309
+ "id" => "task-1",
310
+ "contextId" => "ctx-1",
311
+ "status" => {
312
+ "state" => "TASK_STATE_COMPLETED"
313
+ }
314
+ }
315
+ })]
316
+ }
317
+ end
318
+ end
319
+
320
+ result = client.send_message(
321
+ message: {
322
+ message_id: "msg-1",
323
+ role: "ROLE_USER",
324
+ parts: [{ text: "Hello" }]
325
+ }
326
+ )
327
+ result.should.be.kind_of(A2A::Schema::Definition)
328
+ captured_env.request_headers["content-type"].should == "application/a2a+json"
329
+ end
330
+
331
+ it "rest: get_task uses GET /tasks/{id}" do
332
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
333
+ f.adapter :test do |stub|
334
+ stub.get("/tasks/task-123") { |env|
335
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({
336
+ "id" => "task-123",
337
+ "contextId" => "ctx-456",
338
+ "status" => {
339
+ "state" => "TASK_STATE_SUBMITTED"
340
+ }
341
+ })]
342
+ }
343
+ end
344
+ end
345
+
346
+ result = client.get_task(id: "task-123")
347
+ result.should.be.kind_of(A2A::Schema::Definition)
348
+ result.id.should == "task-123"
349
+ end
350
+
351
+ it "rest: list_tasks uses GET /tasks" do
352
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
353
+ f.adapter :test do |stub|
354
+ stub.get("/tasks") { |env|
355
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({
356
+ "tasks" => [
357
+ { "id" => "t-1", "contextId" => "c-1", "status" => { "state" => "TASK_STATE_COMPLETED" } }
358
+ ]
359
+ })]
360
+ }
361
+ end
362
+ end
363
+
364
+ result = client.list_tasks
365
+ result.should.be.kind_of(A2A::Schema::Definition)
366
+ end
367
+
368
+ it "rest: cancel_task uses POST /tasks/{id}:cancel" do
369
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
370
+ f.adapter :test do |stub|
371
+ stub.post("/tasks/task-123:cancel") { |env|
372
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({
373
+ "id" => "task-123", "contextId" => "ctx-456",
374
+ "status" => { "state" => "TASK_STATE_CANCELED" }
375
+ })]
376
+ }
377
+ end
378
+ end
379
+
380
+ result = client.cancel_task(id: "task-123")
381
+ result.should.be.kind_of(A2A::Schema::Definition)
382
+ result.id.should == "task-123"
383
+ end
384
+
385
+ it "rest: create_task_push_notification_config uses POST /tasks/{task_id}/pushNotificationConfigs" do
386
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
387
+ f.adapter :test do |stub|
388
+ stub.post("/tasks/task-123/pushNotificationConfigs") { |env|
389
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({
390
+ "id" => "config-1", "taskId" => "task-123",
391
+ "url" => "https://example.com/webhook"
392
+ })]
393
+ }
394
+ end
395
+ end
396
+
397
+ result = client.create_task_push_notification_config(
398
+ task_id: "task-123", url: "https://example.com/webhook"
399
+ )
400
+ result.should.be.kind_of(A2A::Schema::Definition)
401
+ end
402
+
403
+ it "rest: get_task_push_notification_config uses GET /tasks/{task_id}/pushNotificationConfigs/{id}" do
404
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
405
+ f.adapter :test do |stub|
406
+ stub.get("/tasks/task-123/pushNotificationConfigs/config-1") { |env|
407
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({
408
+ "id" => "config-1", "taskId" => "task-123",
409
+ "url" => "https://example.com/webhook"
410
+ })]
411
+ }
412
+ end
413
+ end
414
+
415
+ result = client.get_task_push_notification_config(id: "config-1", task_id: "task-123")
416
+ result.should.be.kind_of(A2A::Schema::Definition)
417
+ end
418
+
419
+ it "rest: list_task_push_notification_configs uses GET /tasks/{task_id}/pushNotificationConfigs" do
420
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
421
+ f.adapter :test do |stub|
422
+ stub.get("/tasks/task-123/pushNotificationConfigs") { |env|
423
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({
424
+ "pushNotificationConfigs" => []
425
+ })]
426
+ }
427
+ end
428
+ end
429
+
430
+ result = client.list_task_push_notification_configs(task_id: "task-123")
431
+ result.should.be.kind_of(A2A::Schema::Definition)
432
+ end
433
+
434
+ it "rest: delete_task_push_notification_config uses DELETE /tasks/{task_id}/pushNotificationConfigs/{id}" do
435
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
436
+ f.adapter :test do |stub|
437
+ stub.delete("/tasks/task-123/pushNotificationConfigs/config-1") { |env|
438
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({})]
439
+ }
440
+ end
441
+ end
442
+
443
+ result = client.delete_task_push_notification_config(id: "config-1", task_id: "task-123")
444
+ result.should == {}
445
+ end
446
+
447
+ it "rest: get_extended_agent_card uses GET /extendedAgentCard" do
448
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
449
+ f.adapter :test do |stub|
450
+ stub.get("/extendedAgentCard") { |env|
451
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({
452
+ "name" => "Extended Agent",
453
+ "version" => "2.0.0",
454
+ "capabilities" => {
455
+ "streaming" => true,
456
+ "extendedAgentCard" => true
457
+ }
458
+ })]
459
+ }
460
+ end
461
+ end
462
+
463
+ result = client.get_extended_agent_card
464
+ result.should.be.kind_of(A2A::Schema::Definition)
465
+ result.name.should == "Extended Agent"
466
+ end
467
+
468
+ it "rest: subscribe_to_task uses GET /tasks/{id}:subscribe with Accept header" do
469
+ captured_env = nil
470
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
471
+ f.adapter :test do |stub|
472
+ stub.get("/tasks/task-1:subscribe") { |env|
473
+ captured_env = env
474
+ [200, { "content-type" => "text/event-stream" }, ""]
475
+ }
476
+ end
477
+ end
478
+
479
+ client.subscribe_to_task(id: "task-1") do |event|
480
+ end
481
+
482
+ captured_env.request_headers["Accept"].should == "text/event-stream"
483
+ end
484
+
485
+ it "rest: raises on HTTP 400 error" do
486
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
487
+ f.adapter :test do |stub|
488
+ stub.get("/tasks/bad-id") { |env|
489
+ [400, { "content-type" => "application/problem+json" }, JSON.generate({
490
+ "type" => "error",
491
+ "title" => "Bad Request","status" => 400
492
+ })]
493
+ }
494
+ end
495
+ end
496
+
497
+ lambda { client.get_task(id: "bad-id") }.should.raise(A2A::RestError)
498
+ end
499
+
500
+ it "rest: raises ValidationError on invalid params" do
501
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
502
+ f.adapter :test do |stub|
503
+ stub.post("/message:send") { |env| [200, { "content-type" => "application/a2a+json" }, "{}"] }
504
+ end
505
+ end
506
+
507
+ lambda { client.send_message(message: "not_a_hash") }.should.raise(A2A::Schema::ValidationError)
89
508
  end
90
509
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+
6
+ module A2A
7
+ # Raised by the JSON-RPC response middleware when the server returns
8
+ # a JSON-RPC 2.0 error envelope. Preserves the wire code, message,
9
+ # and optional structured data array.
10
+ #
11
+ # JSON-RPC errors are always delivered over HTTP 200 — the error
12
+ # lives inside the JSON-RPC envelope, not the HTTP status.
13
+ #
14
+ class JsonRpcError < Error
15
+ def initialize(message, code:, data: nil)
16
+ @wire_data = data
17
+ super(message, code: code, http_status: 200)
18
+ end
19
+
20
+ def error_data
21
+ @wire_data
22
+ end
23
+ end
24
+ end
25
+
26
+ test do
27
+ describe "A2A::JsonRpcError" do
28
+ it "has correct code and message" do
29
+ err = A2A::JsonRpcError.new("Task not found", code: -32001)
30
+ err.code.should == -32001
31
+ err.message.should == "Task not found"
32
+ end
33
+
34
+ it "always has http_status 200" do
35
+ err = A2A::JsonRpcError.new("fail", code: -32001)
36
+ err.http_status.should == 200
37
+ end
38
+
39
+ it "preserves wire data" do
40
+ data = [{
41
+ "@type" => "type.googleapis.com/google.rpc.ErrorInfo",
42
+ "reason" => "TASK_NOT_FOUND",
43
+ "domain" => "a2a-protocol.org",
44
+ "metadata" => { "taskId" => "t-1" },
45
+ }]
46
+ err = A2A::JsonRpcError.new("Task not found", code: -32001, data: data)
47
+ err.error_data.should == data
48
+ err.error_data.first["reason"].should == "TASK_NOT_FOUND"
49
+ end
50
+
51
+ it "returns nil error_data when no data provided" do
52
+ err = A2A::JsonRpcError.new("fail", code: -32600)
53
+ err.error_data.should.be.nil
54
+ end
55
+
56
+ it "is a subclass of A2A::Error" do
57
+ err = A2A::JsonRpcError.new("fail", code: -32001)
58
+ err.is_a?(A2A::Error).should == true
59
+ end
60
+
61
+ it "serializes to_h with wire data" do
62
+ data = [{ "reason" => "TASK_NOT_FOUND" }]
63
+ err = A2A::JsonRpcError.new("Task not found", code: -32001, data: data)
64
+ h = err.to_h
65
+ h[:code].should == -32001
66
+ h[:http_status].should == 200
67
+ h[:message].should == "Task not found"
68
+ h[:data].should == data
69
+ end
70
+ end
71
+ end