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
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+ require "faraday"
6
+ require "json"
7
+
8
+ module A2A
9
+ module Faraday
10
+ module Middleware
11
+ module JsonRpc
12
+ # Faraday response middleware that unwraps JSON-RPC 2.0 responses
13
+ # and converts the result into A2A::Schema::Definition objects.
14
+ #
15
+ # Reads env.request.context[:a2a_operation] to determine which
16
+ # Schema class to instantiate. If no operation is set, passes through.
17
+ #
18
+ # Raises on JSON-RPC error responses.
19
+ #
20
+ class Response < ::Faraday::Middleware
21
+ def on_complete(env)
22
+ operation = env.request.context&.dig(:a2a_operation)
23
+ return unless operation
24
+
25
+ parsed = env.body
26
+ return if parsed.nil?
27
+
28
+ # Handle string bodies (if JSON response middleware hasn't run)
29
+ if parsed.is_a?(String)
30
+ return if parsed.empty?
31
+ parsed = JSON.parse(parsed)
32
+ end
33
+
34
+ # Handle JSON-RPC error responses
35
+ if parsed.is_a?(Hash) && (err = parsed["error"])
36
+ raise A2A::JsonRpcError.new(err["message"], code: err["code"], data: err["data"])
37
+ end
38
+
39
+ # Extract result from JSON-RPC envelope
40
+ result = parsed.is_a?(Hash) && parsed.key?("result") ? parsed["result"] : parsed
41
+
42
+ schema_class = operation.response_schema
43
+ unless schema_class
44
+ env.body = result
45
+ return
46
+ end
47
+
48
+ env.body = schema_class.new(result)
49
+ rescue JSON::ParserError
50
+ # Leave body as-is if JSON parsing fails
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ ::Faraday::Response.register_middleware(a2a_json_rpc: A2A::Faraday::Middleware::JsonRpc::Response)
59
+
60
+ test do
61
+ middleware = A2A::Faraday::Middleware::JsonRpc::Response
62
+ operation = A2A::Proto.operation("GetTask")
63
+
64
+ it "unwraps JSON-RPC result into Schema object" do
65
+ env = ::Faraday::Env.new
66
+ env.body = {
67
+ "jsonrpc" => "2.0", "id" => 1,
68
+ "result" => {
69
+ "id" => "task-123", "contextId" => "ctx-456",
70
+ "status" => { "state" => "TASK_STATE_SUBMITTED" }
71
+ }
72
+ }
73
+ env.request = ::Faraday::RequestOptions.new
74
+ env.request.context = { a2a_operation: operation }
75
+
76
+ middleware.new(nil).on_complete(env)
77
+
78
+ env.body.should.be.kind_of(A2A::Schema::Definition)
79
+ env.body.id.should == "task-123"
80
+ env.body.context_id.should == "ctx-456"
81
+ end
82
+
83
+ it "raises on JSON-RPC error" do
84
+ env = ::Faraday::Env.new
85
+ env.body = {
86
+ "jsonrpc" => "2.0", "id" => 1,
87
+ "error" => { "code" => -32600, "message" => "Invalid Request" }
88
+ }
89
+ env.request = ::Faraday::RequestOptions.new
90
+ env.request.context = { a2a_operation: operation }
91
+
92
+ lambda { middleware.new(nil).on_complete(env) }.should.raise(A2A::JsonRpcError)
93
+ end
94
+
95
+ it "returns raw hash when response_schema is nil" do
96
+ op = A2A::Proto.operation("DeleteTaskPushNotificationConfig")
97
+ env = ::Faraday::Env.new
98
+ env.body = { "jsonrpc" => "2.0", "id" => 1, "result" => {} }
99
+ env.request = ::Faraday::RequestOptions.new
100
+ env.request.context = { a2a_operation: op }
101
+
102
+ middleware.new(nil).on_complete(env)
103
+
104
+ env.body.should == {}
105
+ end
106
+
107
+ it "passes through when no operation is set" do
108
+ env = ::Faraday::Env.new
109
+ env.body = { "foo" => "bar" }
110
+ env.request = ::Faraday::RequestOptions.new
111
+
112
+ middleware.new(nil).on_complete(env)
113
+
114
+ env.body.should == { "foo" => "bar" }
115
+ end
116
+
117
+ it "handles string body" do
118
+ env = ::Faraday::Env.new
119
+ env.body = JSON.generate({
120
+ "jsonrpc" => "2.0", "id" => 1,
121
+ "result" => { "id" => "task-1", "contextId" => "ctx-1", "status" => { "state" => "TASK_STATE_COMPLETED" } }
122
+ })
123
+ env.request = ::Faraday::RequestOptions.new
124
+ env.request.context = { a2a_operation: operation }
125
+
126
+ middleware.new(nil).on_complete(env)
127
+
128
+ env.body.should.be.kind_of(A2A::Schema::Definition)
129
+ env.body.id.should == "task-1"
130
+ end
131
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+ require "faraday"
6
+
7
+ module A2A
8
+ module Faraday
9
+ module Middleware
10
+ module REST
11
+ # Faraday request middleware that rewrites the request for the
12
+ # A2A HTTP+JSON/REST protocol binding.
13
+ #
14
+ # Reads env.request.context[:a2a_operation] to determine the
15
+ # HTTP verb and path. Interpolates path parameters from the
16
+ # request body (e.g. {id=*} placeholders). For GET/DELETE
17
+ # operations, remaining params become query string parameters.
18
+ #
19
+ # Sets Content-Type to application/a2a+json for POST requests.
20
+ #
21
+ class Request < ::Faraday::Middleware
22
+ def on_request(env)
23
+ operation = env.request.context&.dig(:a2a_operation)
24
+ return unless operation
25
+
26
+ params = env.body || {}
27
+ path = interpolate_path(operation.rest_path, params)
28
+ remaining = remove_path_params(operation.rest_path, params)
29
+
30
+ env.url.path = path
31
+ env.method = operation.rest_verb.to_sym
32
+
33
+ if [:get, :delete].include?(env.method)
34
+ remaining.each { |k, v| env.params[k.to_s] = v }
35
+ env.body = nil
36
+ else
37
+ env.body = remaining
38
+ env.request_headers["content-type"] = "application/a2a+json"
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ # Extract {name=*} placeholder names from a path pattern.
45
+ def path_param_names(pattern)
46
+ pattern.scan(/\{(\w+)(?:=[^}]*)?\}/).flatten
47
+ end
48
+
49
+ # Substitute {name=*} placeholders with values from params.
50
+ # Pattern names are snake_case (e.g. task_id) but Schema#to_h
51
+ # produces camelCase keys (e.g. taskId), so we check both.
52
+ def interpolate_path(pattern, params)
53
+ pattern.gsub(/\{(\w+)(?:=[^}]*)?\}/) do
54
+ name = $1
55
+ camel = snake_to_camel(name)
56
+ params[name] || params[name.to_sym] ||
57
+ params[camel] || params[camel.to_sym] || ""
58
+ end
59
+ end
60
+
61
+ # Return params with path-interpolated keys removed.
62
+ def remove_path_params(pattern, params)
63
+ names = path_param_names(pattern)
64
+ camel_names = names.map { |n| snake_to_camel(n) }
65
+ all_names = (names + camel_names).to_a
66
+ params.reject { |k, _| all_names.include?(k.to_s) }
67
+ end
68
+
69
+ # "task_id" => "taskId"
70
+ def snake_to_camel(str)
71
+ str.gsub(/_([a-z])/) { $1.upcase }
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ ::Faraday::Request.register_middleware(a2a_rest: A2A::Faraday::Middleware::REST::Request)
80
+
81
+ test do
82
+ middleware = A2A::Faraday::Middleware::REST::Request
83
+
84
+ it "rewrites POST operation with correct path, verb, and content-type" do
85
+ operation = A2A::Proto.operation("SendMessage")
86
+ env = ::Faraday::Env.new
87
+ env.url = URI.parse("http://localhost:9292/")
88
+ env.body = { "message" => { "role" => "ROLE_USER" } }
89
+ env.params = {}
90
+ env.request_headers = {}
91
+ env.request = ::Faraday::RequestOptions.new
92
+ env.request.context = { a2a_operation: operation }
93
+
94
+ middleware.new(nil).on_request(env)
95
+
96
+ env.url.path.should == "/message:send"
97
+ env.method.should == :post
98
+ env.body.should == { "message" => { "role" => "ROLE_USER" } }
99
+ env.request_headers["content-type"].should == "application/a2a+json"
100
+ end
101
+
102
+ it "rewrites GET operation with path interpolation and query params" do
103
+ operation = A2A::Proto.operation("GetTask")
104
+ env = ::Faraday::Env.new
105
+ env.url = URI.parse("http://localhost:9292/")
106
+ env.body = { "id" => "task-123", "historyLength" => "5" }
107
+ env.params = {}
108
+ env.request_headers = {}
109
+ env.request = ::Faraday::RequestOptions.new
110
+ env.request.context = { a2a_operation: operation }
111
+
112
+ middleware.new(nil).on_request(env)
113
+
114
+ env.url.path.should == "/tasks/task-123"
115
+ env.method.should == :get
116
+ env.body.should.be.nil
117
+ env.params["historyLength"].should == "5"
118
+ end
119
+
120
+ it "rewrites DELETE operation with multi-param path interpolation" do
121
+ operation = A2A::Proto.operation("DeleteTaskPushNotificationConfig")
122
+ env = ::Faraday::Env.new
123
+ env.url = URI.parse("http://localhost:9292/")
124
+ env.body = { "taskId" => "task-123", "id" => "config-1" }
125
+ env.params = {}
126
+ env.request_headers = {}
127
+ env.request = ::Faraday::RequestOptions.new
128
+ env.request.context = { a2a_operation: operation }
129
+
130
+ middleware.new(nil).on_request(env)
131
+
132
+ env.url.path.should == "/tasks/task-123/pushNotificationConfigs/config-1"
133
+ env.method.should == :delete
134
+ env.body.should.be.nil
135
+ end
136
+
137
+ it "rewrites POST cancel with path interpolation" do
138
+ operation = A2A::Proto.operation("CancelTask")
139
+ env = ::Faraday::Env.new
140
+ env.url = URI.parse("http://localhost:9292/")
141
+ env.body = { "id" => "task-123", "metadata" => { "key" => "val" } }
142
+ env.params = {}
143
+ env.request_headers = {}
144
+ env.request = ::Faraday::RequestOptions.new
145
+ env.request.context = { a2a_operation: operation }
146
+
147
+ middleware.new(nil).on_request(env)
148
+
149
+ env.url.path.should == "/tasks/task-123:cancel"
150
+ env.method.should == :post
151
+ env.body.should == { "metadata" => { "key" => "val" } }
152
+ end
153
+
154
+ it "passes through when no operation is set" do
155
+ env = ::Faraday::Env.new
156
+ env.url = URI.parse("http://localhost:9292/")
157
+ env.body = { "foo" => "bar" }
158
+ env.params = {}
159
+ env.request_headers = {}
160
+ env.request = ::Faraday::RequestOptions.new
161
+
162
+ middleware.new(nil).on_request(env)
163
+
164
+ env.body.should == { "foo" => "bar" }
165
+ end
166
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+ require "faraday"
6
+ require "json"
7
+
8
+ module A2A
9
+ module Faraday
10
+ module Middleware
11
+ module REST
12
+ # Faraday response middleware for the A2A HTTP+JSON/REST binding.
13
+ #
14
+ # Unlike the JSON-RPC binding, REST responses have no envelope —
15
+ # the body IS the result directly. Errors are signaled via HTTP
16
+ # status codes with application/problem+json bodies.
17
+ #
18
+ # Reads env.request.context[:a2a_operation] to determine which
19
+ # Schema class to instantiate. If no operation is set, passes through.
20
+ #
21
+ class Response < ::Faraday::Middleware
22
+ def on_complete(env)
23
+ operation = env.request.context&.dig(:a2a_operation)
24
+ return unless operation
25
+
26
+ if env.status >= 400
27
+ parsed = env.body
28
+ if parsed.is_a?(Hash)
29
+ message = parsed["title"] || parsed["message"] || parsed.to_s
30
+ data = parsed["detail"]
31
+ else
32
+ message = parsed.to_s
33
+ data = nil
34
+ end
35
+ raise A2A::RestError.new(message, http_status: env.status, data: data)
36
+ end
37
+
38
+ parsed = env.body
39
+ return if parsed.nil?
40
+
41
+ if parsed.is_a?(String)
42
+ return if parsed.empty?
43
+ parsed = JSON.parse(parsed)
44
+ end
45
+
46
+ schema_class = operation.response_schema
47
+ unless schema_class
48
+ env.body = parsed
49
+ return
50
+ end
51
+
52
+ env.body = schema_class.new(parsed)
53
+ rescue JSON::ParserError
54
+ # Leave body as-is if JSON parsing fails
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ ::Faraday::Response.register_middleware(a2a_rest: A2A::Faraday::Middleware::REST::Response)
63
+
64
+ test do
65
+ middleware = A2A::Faraday::Middleware::REST::Response
66
+ operation = A2A::Proto.operation("GetTask")
67
+
68
+ it "wraps response body in Schema object directly (no envelope)" do
69
+ env = ::Faraday::Env.new
70
+ env.status = 200
71
+ env.body = {
72
+ "id" => "task-123", "contextId" => "ctx-456",
73
+ "status" => { "state" => "TASK_STATE_SUBMITTED" }
74
+ }
75
+ env.request = ::Faraday::RequestOptions.new
76
+ env.request.context = { a2a_operation: operation }
77
+
78
+ middleware.new(nil).on_complete(env)
79
+
80
+ env.body.should.be.kind_of(A2A::Schema::Definition)
81
+ env.body.id.should == "task-123"
82
+ env.body.context_id.should == "ctx-456"
83
+ end
84
+
85
+ it "returns raw hash when response_schema is nil" do
86
+ op = A2A::Proto.operation("DeleteTaskPushNotificationConfig")
87
+ env = ::Faraday::Env.new
88
+ env.status = 200
89
+ env.body = {}
90
+ env.request = ::Faraday::RequestOptions.new
91
+ env.request.context = { a2a_operation: op }
92
+
93
+ middleware.new(nil).on_complete(env)
94
+
95
+ env.body.should == {}
96
+ end
97
+
98
+ it "raises on HTTP 4xx error" do
99
+ env = ::Faraday::Env.new
100
+ env.status = 400
101
+ env.body = { "type" => "error", "title" => "Bad Request", "status" => 400 }
102
+ env.request = ::Faraday::RequestOptions.new
103
+ env.request.context = { a2a_operation: operation }
104
+
105
+ lambda { middleware.new(nil).on_complete(env) }.should.raise(A2A::RestError)
106
+ end
107
+
108
+ it "raises on HTTP 5xx error" do
109
+ env = ::Faraday::Env.new
110
+ env.status = 500
111
+ env.body = { "type" => "error", "title" => "Internal Server Error", "status" => 500 }
112
+ env.request = ::Faraday::RequestOptions.new
113
+ env.request.context = { a2a_operation: operation }
114
+
115
+ lambda { middleware.new(nil).on_complete(env) }.should.raise(A2A::RestError)
116
+ end
117
+
118
+ it "passes through when no operation is set" do
119
+ env = ::Faraday::Env.new
120
+ env.status = 200
121
+ env.body = { "foo" => "bar" }
122
+ env.request = ::Faraday::RequestOptions.new
123
+
124
+ middleware.new(nil).on_complete(env)
125
+
126
+ env.body.should == { "foo" => "bar" }
127
+ end
128
+
129
+ it "handles string body" do
130
+ env = ::Faraday::Env.new
131
+ env.status = 200
132
+ env.body = JSON.generate({
133
+ "id" => "task-1", "contextId" => "ctx-1",
134
+ "status" => { "state" => "TASK_STATE_COMPLETED" }
135
+ })
136
+ env.request = ::Faraday::RequestOptions.new
137
+ env.request.context = { a2a_operation: operation }
138
+
139
+ middleware.new(nil).on_complete(env)
140
+
141
+ env.body.should.be.kind_of(A2A::Schema::Definition)
142
+ env.body.id.should == "task-1"
143
+ end
144
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+ require "faraday"
6
+ require "json"
7
+
8
+ module A2A
9
+ module Faraday
10
+ module Middleware
11
+ # Faraday request middleware that converts A2A::Schema::Definition
12
+ # objects into their hash representation for JSON serialization.
13
+ #
14
+ # Place this before the :json request middleware in the stack so
15
+ # that Schema bodies are converted to hashes before Faraday's
16
+ # JSON middleware serializes them to strings.
17
+ #
18
+ class SchemaRequest < ::Faraday::Middleware
19
+ def on_request(env)
20
+ body = env.body
21
+ return unless body.is_a?(A2A::Schema::Definition)
22
+
23
+ env.body = body.to_h
24
+ env.request_headers["content-type"] ||= "application/json"
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ ::Faraday::Request.register_middleware(a2a_schema: A2A::Faraday::Middleware::SchemaRequest)
32
+
33
+ test do
34
+ middleware = A2A::Faraday::Middleware::SchemaRequest
35
+
36
+ it "converts Schema::Definition to hash" do
37
+ schema_obj = A2A::Schema["Agent Capabilities"].new(streaming: true)
38
+ env = ::Faraday::Env.new
39
+ env.body = schema_obj
40
+ env.request_headers = {}
41
+
42
+ middleware.new(nil).on_request(env)
43
+
44
+ env.body.should == { "streaming" => true }
45
+ env.request_headers["content-type"].should == "application/json"
46
+ end
47
+
48
+ it "does not modify non-Schema bodies" do
49
+ env = ::Faraday::Env.new
50
+ env.body = { "foo" => "bar" }
51
+ env.request_headers = {}
52
+
53
+ middleware.new(nil).on_request(env)
54
+
55
+ env.body.should == { "foo" => "bar" }
56
+ env.request_headers["content-type"].should.be.nil
57
+ end
58
+
59
+ it "does not overwrite existing content-type" do
60
+ schema_obj = A2A::Schema["Agent Capabilities"].new(streaming: true)
61
+ env = ::Faraday::Env.new
62
+ env.body = schema_obj
63
+ env.request_headers = { "content-type" => "application/a2a+json" }
64
+
65
+ middleware.new(nil).on_request(env)
66
+
67
+ env.request_headers["content-type"].should == "application/a2a+json"
68
+ end
69
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+
6
+ module A2A
7
+ module Middleware
8
+ # Extracts text from the request message's parts and sets
9
+ # `env["a2a.message"]` to the joined text string.
10
+ #
11
+ # Replaces the common `extract_text` lambda pattern found in examples:
12
+ #
13
+ # extract_text = ->(message) {
14
+ # parts = message.parts || []
15
+ # parts.filter_map { |p| p.text }.join("\n")
16
+ # }
17
+ #
18
+ # Usage:
19
+ #
20
+ # on "SendMessage" do
21
+ # use A2A::Middleware::ExtractMessage
22
+ # respond_with -> (env) {
23
+ # text = env["a2a.message"]
24
+ # # ...
25
+ # }
26
+ # end
27
+ #
28
+ class ExtractMessage
29
+ def initialize(app)
30
+ @app = app
31
+ end
32
+
33
+ def call(env)
34
+ request = env["a2a.request"]
35
+ message = request.message
36
+ parts = message.parts || []
37
+
38
+ env["a2a.message"] = parts.filter_map { |p|
39
+ p.respond_to?(:text) ? p.text : p["text"]
40
+ }.join("\n")
41
+
42
+ @app.call(env)
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ test do
49
+ describe "A2A::Middleware::ExtractMessage" do
50
+ it "extracts text from message parts" do
51
+ part1 = Object.new
52
+ part1.define_singleton_method(:text) { "hello" }
53
+ part2 = Object.new
54
+ part2.define_singleton_method(:text) { "world" }
55
+
56
+ message = Object.new
57
+ message.define_singleton_method(:parts) { [part1, part2] }
58
+
59
+ request = Object.new
60
+ request.define_singleton_method(:message) { message }
61
+
62
+ downstream = -> (env) { env["a2a.message"] }
63
+ mw = A2A::Middleware::ExtractMessage.new(downstream)
64
+ env = { "a2a.request" => request }
65
+
66
+ result = mw.call(env)
67
+ result.should == "hello\nworld"
68
+ end
69
+
70
+ it "handles nil parts" do
71
+ message = Object.new
72
+ message.define_singleton_method(:parts) { nil }
73
+
74
+ request = Object.new
75
+ request.define_singleton_method(:message) { message }
76
+
77
+ downstream = -> (env) { env["a2a.message"] }
78
+ mw = A2A::Middleware::ExtractMessage.new(downstream)
79
+ env = { "a2a.request" => request }
80
+
81
+ result = mw.call(env)
82
+ result.should == ""
83
+ end
84
+
85
+ it "skips parts without text" do
86
+ part1 = Object.new
87
+ part1.define_singleton_method(:text) { "hello" }
88
+ part2 = Object.new
89
+ part2.define_singleton_method(:text) { nil }
90
+
91
+ message = Object.new
92
+ message.define_singleton_method(:parts) { [part1, part2] }
93
+
94
+ request = Object.new
95
+ request.define_singleton_method(:message) { message }
96
+
97
+ downstream = -> (env) { env["a2a.message"] }
98
+ mw = A2A::Middleware::ExtractMessage.new(downstream)
99
+ env = { "a2a.request" => request }
100
+
101
+ result = mw.call(env)
102
+ result.should == "hello"
103
+ end
104
+
105
+ it "handles hash-style parts" do
106
+ message = Object.new
107
+ message.define_singleton_method(:parts) { [{ "text" => "from hash" }] }
108
+
109
+ request = Object.new
110
+ request.define_singleton_method(:message) { message }
111
+
112
+ downstream = -> (env) { env["a2a.message"] }
113
+ mw = A2A::Middleware::ExtractMessage.new(downstream)
114
+ env = { "a2a.request" => request }
115
+
116
+ result = mw.call(env)
117
+ result.should == "from hash"
118
+ end
119
+ end
120
+ end