agent2agent 1.0.8 → 1.1.1

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 +469 -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 +170 -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,507 @@
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
+
88
+ f.response :a2a_rest
89
+ f.response :json
53
90
 
54
- if (error = parsed["error"])
55
- raise "JSON-RPC error #{error["code"]}: #{error["message"]}"
91
+ f.adapter :async_http
92
+
93
+ block&.call(f)
56
94
  end
95
+ end
96
+
97
+ def invoke(operation, params)
98
+ request = operation.request_schema.new(params)
99
+ request.valid!
100
+
101
+ response = @conn.post("/") do |req|
102
+ req.options.context = { a2a_operation: operation }
103
+ req.body = request
104
+ end
105
+
106
+ response.body
107
+ end
108
+
109
+ def invoke_streaming(operation, params, &block)
110
+ request = operation.request_schema.new(params)
111
+ request.valid!
112
+
113
+ parser = A2A::SSE::EventParser.new(binding: @binding)
114
+
115
+ @conn.post("/") do |req|
116
+ req.options.context = { a2a_operation: operation }
117
+ req.body = request
118
+ req.headers["Accept"] = "text/event-stream"
119
+
120
+ if block
121
+ req.options.on_data = proc do |chunk, _size, _env|
122
+ parser.feed(chunk) { |event| block.call(event) }
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ test do
131
+ # --- JSON-RPC binding (default) ---
132
+
133
+ it "generates methods for all Proto operations" do
134
+ client = A2A::Client.new("http://localhost:9292") do |f|
135
+ f.adapter :test do |stub|
136
+ stub.post("/a2a") { |env|
137
+ [200, { "content-type" => "application/json" }, JSON.generate({ "jsonrpc" => "2.0", "id" => 1, "result" => {} })]
138
+ }
139
+ end
140
+ end
57
141
 
58
- parsed["result"]
142
+ expected = %w[
143
+ send_message
144
+ send_streaming_message
145
+ get_task
146
+ list_tasks
147
+ cancel_task
148
+ subscribe_to_task
149
+ create_task_push_notification_config
150
+ get_task_push_notification_config
151
+ list_task_push_notification_configs
152
+ delete_task_push_notification_config
153
+ get_extended_agent_card
154
+ ]
155
+
156
+ expected.each do |name|
157
+ client.respond_to?(name).should == true
158
+ end
159
+ end
160
+
161
+ it "agent_card returns a Schema object" do
162
+ client = A2A::Client.new("http://localhost:9292") do |f|
163
+ f.adapter :test do |stub|
164
+ stub.get("/.well-known/agent-card.json") { |env|
165
+ [200, { "content-type" => "application/json" }, JSON.generate({
166
+ "name" => "Test Agent",
167
+ "version" => "1.0.0",
168
+ "capabilities" => { "streaming" => true }
169
+ })]
170
+ }
59
171
  end
172
+ end
173
+
174
+ card = client.agent_card
175
+ card.should.be.kind_of(A2A::Schema::Definition)
176
+ card.name.should == "Test Agent"
177
+ card.version.should == "1.0.0"
178
+ end
179
+
180
+ it "json_rpc: send_message validates, wraps in JSON-RPC, returns Schema" do
181
+ client = A2A::Client.new("http://localhost:9292") do |f|
182
+ f.adapter :test do |stub|
183
+ stub.post("/a2a") { |env|
184
+ parsed = JSON.parse(env.body)
185
+ parsed["method"].should == "SendMessage"
186
+ parsed["params"]["message"]["role"].should == "ROLE_USER"
60
187
 
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
188
+ [200, { "content-type" => "application/json" }, JSON.generate({
189
+ "jsonrpc" => "2.0", "id" => parsed["id"],
190
+ "result" => {
191
+ "task" => {
192
+ "id" => "task-1",
193
+ "contextId" => "ctx-1",
194
+ "status" => { "state" => "TASK_STATE_COMPLETED" },
195
+ "artifacts" => [{
196
+ "artifactId" => "a-1",
197
+ "parts" => [{ "text" => "Echo: Hello" }]
198
+ }]
199
+ }
200
+ }
201
+ })]
202
+ }
69
203
  end
204
+ end
205
+
206
+ result = client.send_message(
207
+ message: {
208
+ message_id: "msg-1",
209
+ role: "ROLE_USER",
210
+ parts: [{ text: "Hello" }]
211
+ }
212
+ )
213
+ result.should.be.kind_of(A2A::Schema::Definition)
214
+ end
215
+
216
+ it "json_rpc: get_task returns a Task" do
217
+ client = A2A::Client.new("http://localhost:9292") do |f|
218
+ f.adapter :test do |stub|
219
+ stub.post("/a2a") { |env|
220
+ parsed = JSON.parse(env.body)
221
+ parsed["method"].should == "GetTask"
222
+ parsed["params"]["id"].should == "task-123"
70
223
 
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
224
+ [200, { "content-type" => "application/json" }, JSON.generate({
225
+ "jsonrpc" => "2.0", "id" => parsed["id"],
226
+ "result" => {
227
+ "id" => "task-123",
228
+ "contextId" => "ctx-456",
229
+ "status" => {
230
+ "state" => "TASK_STATE_SUBMITTED"
231
+ }
232
+ }
233
+ })]
234
+ }
84
235
  end
236
+ end
237
+
238
+ result = client.get_task(id: "task-123")
239
+ result.should.be.kind_of(A2A::Schema::Definition)
240
+ result.id.should == "task-123"
241
+ result.context_id.should == "ctx-456"
242
+ end
85
243
 
86
- def next_id
87
- @id_counter = (@id_counter || 0) + 1
244
+ it "json_rpc: raises on JSON-RPC error" do
245
+ client = A2A::Client.new("http://localhost:9292") do |f|
246
+ f.adapter :test do |stub|
247
+ stub.post("/a2a") { |env|
248
+ [200, { "content-type" => "application/json" }, JSON.generate({
249
+ "jsonrpc" => "2.0",
250
+ "id" => 1,
251
+ "error" => {
252
+ "code" => -32600,
253
+ "message" => "Invalid Request"
254
+ }
255
+ })]
256
+ }
88
257
  end
258
+ end
259
+
260
+ lambda { client.get_task(id: "task-123") }.should.raise(A2A::JsonRpcError)
261
+ end
262
+
263
+ it "json_rpc: raises ValidationError on invalid params" do
264
+ client = A2A::Client.new("http://localhost:9292") do |f|
265
+ f.adapter :test do |stub|
266
+ stub.post("/a2a") { |env| [200, { "content-type" => "application/json" }, "{}"] }
267
+ end
268
+ end
269
+
270
+ lambda { client.send_message(message: "not_a_hash") }.should.raise(A2A::Schema::ValidationError)
271
+ end
272
+
273
+ it "json_rpc: send_streaming_message sends correct method and Accept header" do
274
+ captured_env = nil
275
+ client = A2A::Client.new("http://localhost:9292") do |f|
276
+ f.adapter :test do |stub|
277
+ stub.post("/a2a") { |env|
278
+ captured_env = env
279
+ [200, { "content-type" => "text/event-stream" }, ""]
280
+ }
281
+ end
282
+ end
283
+
284
+ client.send_streaming_message(
285
+ message: {
286
+ message_id: "msg-1",
287
+ role: "ROLE_USER",
288
+ parts: [{ text: "Hello" }]
289
+ }
290
+ ) do |event|
291
+ end
292
+
293
+ parsed = JSON.parse(captured_env.request_body)
294
+ parsed["method"].should == "SendStreamingMessage"
295
+ captured_env.request_headers["Accept"].should == "text/event-stream"
296
+ end
297
+
298
+ # --- REST binding ---
299
+
300
+ it "rest: send_message posts to /message:send with application/a2a+json" do
301
+ captured_env = nil
302
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
303
+ f.adapter :test do |stub|
304
+ stub.post("/message:send") { |env|
305
+ captured_env = env
306
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({
307
+ "task" => {
308
+ "id" => "task-1",
309
+ "contextId" => "ctx-1",
310
+ "status" => {
311
+ "state" => "TASK_STATE_COMPLETED"
312
+ }
313
+ }
314
+ })]
315
+ }
316
+ end
317
+ end
318
+
319
+ result = client.send_message(
320
+ message: {
321
+ message_id: "msg-1",
322
+ role: "ROLE_USER",
323
+ parts: [{ text: "Hello" }]
324
+ }
325
+ )
326
+ result.should.be.kind_of(A2A::Schema::Definition)
327
+ captured_env.request_headers["content-type"].should == "application/a2a+json"
328
+ end
329
+
330
+ it "rest: get_task uses GET /tasks/{id}" do
331
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
332
+ f.adapter :test do |stub|
333
+ stub.get("/tasks/task-123") { |env|
334
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({
335
+ "id" => "task-123",
336
+ "contextId" => "ctx-456",
337
+ "status" => {
338
+ "state" => "TASK_STATE_SUBMITTED"
339
+ }
340
+ })]
341
+ }
342
+ end
343
+ end
344
+
345
+ result = client.get_task(id: "task-123")
346
+ result.should.be.kind_of(A2A::Schema::Definition)
347
+ result.id.should == "task-123"
348
+ end
349
+
350
+ it "rest: list_tasks uses GET /tasks" do
351
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
352
+ f.adapter :test do |stub|
353
+ stub.get("/tasks") { |env|
354
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({
355
+ "tasks" => [
356
+ { "id" => "t-1", "contextId" => "c-1", "status" => { "state" => "TASK_STATE_COMPLETED" } }
357
+ ]
358
+ })]
359
+ }
360
+ end
361
+ end
362
+
363
+ result = client.list_tasks
364
+ result.should.be.kind_of(A2A::Schema::Definition)
365
+ end
366
+
367
+ it "rest: cancel_task uses POST /tasks/{id}:cancel" do
368
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
369
+ f.adapter :test do |stub|
370
+ stub.post("/tasks/task-123:cancel") { |env|
371
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({
372
+ "id" => "task-123", "contextId" => "ctx-456",
373
+ "status" => { "state" => "TASK_STATE_CANCELED" }
374
+ })]
375
+ }
376
+ end
377
+ end
378
+
379
+ result = client.cancel_task(id: "task-123")
380
+ result.should.be.kind_of(A2A::Schema::Definition)
381
+ result.id.should == "task-123"
382
+ end
383
+
384
+ it "rest: create_task_push_notification_config uses POST /tasks/{task_id}/pushNotificationConfigs" do
385
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
386
+ f.adapter :test do |stub|
387
+ stub.post("/tasks/task-123/pushNotificationConfigs") { |env|
388
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({
389
+ "id" => "config-1", "taskId" => "task-123",
390
+ "url" => "https://example.com/webhook"
391
+ })]
392
+ }
393
+ end
394
+ end
395
+
396
+ result = client.create_task_push_notification_config(
397
+ task_id: "task-123", url: "https://example.com/webhook"
398
+ )
399
+ result.should.be.kind_of(A2A::Schema::Definition)
400
+ end
401
+
402
+ it "rest: get_task_push_notification_config uses GET /tasks/{task_id}/pushNotificationConfigs/{id}" do
403
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
404
+ f.adapter :test do |stub|
405
+ stub.get("/tasks/task-123/pushNotificationConfigs/config-1") { |env|
406
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({
407
+ "id" => "config-1", "taskId" => "task-123",
408
+ "url" => "https://example.com/webhook"
409
+ })]
410
+ }
411
+ end
412
+ end
413
+
414
+ result = client.get_task_push_notification_config(id: "config-1", task_id: "task-123")
415
+ result.should.be.kind_of(A2A::Schema::Definition)
416
+ end
417
+
418
+ it "rest: list_task_push_notification_configs uses GET /tasks/{task_id}/pushNotificationConfigs" do
419
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
420
+ f.adapter :test do |stub|
421
+ stub.get("/tasks/task-123/pushNotificationConfigs") { |env|
422
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({
423
+ "pushNotificationConfigs" => []
424
+ })]
425
+ }
426
+ end
427
+ end
428
+
429
+ result = client.list_task_push_notification_configs(task_id: "task-123")
430
+ result.should.be.kind_of(A2A::Schema::Definition)
431
+ end
432
+
433
+ it "rest: delete_task_push_notification_config uses DELETE /tasks/{task_id}/pushNotificationConfigs/{id}" do
434
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
435
+ f.adapter :test do |stub|
436
+ stub.delete("/tasks/task-123/pushNotificationConfigs/config-1") { |env|
437
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({})]
438
+ }
439
+ end
440
+ end
441
+
442
+ result = client.delete_task_push_notification_config(id: "config-1", task_id: "task-123")
443
+ result.should == {}
444
+ end
445
+
446
+ it "rest: get_extended_agent_card uses GET /extendedAgentCard" do
447
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
448
+ f.adapter :test do |stub|
449
+ stub.get("/extendedAgentCard") { |env|
450
+ [200, { "content-type" => "application/a2a+json" }, JSON.generate({
451
+ "name" => "Extended Agent",
452
+ "version" => "2.0.0",
453
+ "capabilities" => {
454
+ "streaming" => true,
455
+ "extendedAgentCard" => true
456
+ }
457
+ })]
458
+ }
459
+ end
460
+ end
461
+
462
+ result = client.get_extended_agent_card
463
+ result.should.be.kind_of(A2A::Schema::Definition)
464
+ result.name.should == "Extended Agent"
465
+ end
466
+
467
+ it "rest: subscribe_to_task uses GET /tasks/{id}:subscribe with Accept header" do
468
+ captured_env = nil
469
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
470
+ f.adapter :test do |stub|
471
+ stub.get("/tasks/task-1:subscribe") { |env|
472
+ captured_env = env
473
+ [200, { "content-type" => "text/event-stream" }, ""]
474
+ }
475
+ end
476
+ end
477
+
478
+ client.subscribe_to_task(id: "task-1") do |event|
479
+ end
480
+
481
+ captured_env.request_headers["Accept"].should == "text/event-stream"
482
+ end
483
+
484
+ it "rest: raises on HTTP 400 error" do
485
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
486
+ f.adapter :test do |stub|
487
+ stub.get("/tasks/bad-id") { |env|
488
+ [400, { "content-type" => "application/problem+json" }, JSON.generate({
489
+ "type" => "error",
490
+ "title" => "Bad Request","status" => 400
491
+ })]
492
+ }
493
+ end
494
+ end
495
+
496
+ lambda { client.get_task(id: "bad-id") }.should.raise(A2A::RestError)
497
+ end
498
+
499
+ it "rest: raises ValidationError on invalid params" do
500
+ client = A2A::Client.new("http://localhost:9292", binding: :rest) do |f|
501
+ f.adapter :test do |stub|
502
+ stub.post("/message:send") { |env| [200, { "content-type" => "application/a2a+json" }, "{}"] }
503
+ end
504
+ end
505
+
506
+ lambda { client.send_message(message: "not_a_hash") }.should.raise(A2A::Schema::ValidationError)
89
507
  end
90
508
  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