agent2agent 1.1.1 → 2.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/a2a/agent.rb +98 -241
  3. data/lib/a2a/{faraday → client}/middleware/json_rpc/request.rb +10 -8
  4. data/lib/a2a/{faraday → client}/middleware/json_rpc/response.rb +10 -9
  5. data/lib/a2a/{faraday → client}/middleware/rest/request.rb +24 -18
  6. data/lib/a2a/{faraday → client}/middleware/rest/response.rb +12 -8
  7. data/lib/a2a/{faraday → client}/middleware/schema_request.rb +9 -8
  8. data/lib/a2a/client.rb +40 -39
  9. data/lib/a2a/errors/json_rpc_error.rb +1 -2
  10. data/lib/a2a/errors/rest_error.rb +1 -2
  11. data/lib/a2a/errors.rb +1 -2
  12. data/lib/a2a/protocol/json_schema/definition.rb +276 -0
  13. data/lib/a2a/protocol/json_schema/validation_error.rb +132 -0
  14. data/lib/a2a/{schema.rb → protocol/json_schema.rb} +211 -208
  15. data/lib/a2a/protocol/protobuf.rb +447 -0
  16. data/lib/a2a/protocol.rb +31 -0
  17. data/lib/a2a/server/bindings/grpc.rb +37 -0
  18. data/lib/a2a/server/bindings/json_rpc.rb +29 -9
  19. data/lib/a2a/server/bindings/rest.rb +26 -13
  20. data/lib/a2a/server/middleware/extract_message.rb +145 -0
  21. data/lib/a2a/{middleware → server/middleware}/fetch_task.rb +82 -89
  22. data/lib/a2a/{middleware → server/middleware}/limit_history_length.rb +40 -49
  23. data/lib/a2a/{middleware → server/middleware}/limit_pagination_size.rb +36 -42
  24. data/lib/a2a/server/middleware/sse_stream.rb +236 -0
  25. data/lib/a2a/{sse → server/sse}/event_parser.rb +70 -69
  26. data/lib/a2a/server/sse/json_rpc_stream.rb +89 -0
  27. data/lib/a2a/server/sse/rest_stream.rb +63 -0
  28. data/lib/a2a/server/sse/stream.rb +261 -0
  29. data/lib/a2a/server/triage.rb +35 -4
  30. data/lib/a2a/server/well_known.rb +27 -0
  31. data/lib/a2a/server.rb +44 -58
  32. data/lib/a2a/test_helpers.rb +34 -75
  33. data/lib/a2a/version.rb +1 -1
  34. data/lib/a2a.rb +7 -11
  35. data/lib/traces/provider/a2a/agent.rb +25 -0
  36. data/lib/traces/provider/a2a.rb +1 -1
  37. metadata +39 -37
  38. data/lib/a2a/middleware/extract_message.rb +0 -120
  39. data/lib/a2a/middleware/sse_stream.rb +0 -235
  40. data/lib/a2a/proto.rb +0 -444
  41. data/lib/a2a/schema/definition.rb +0 -148
  42. data/lib/a2a/schema/validation_error.rb +0 -129
  43. data/lib/a2a/server/dispatcher.rb +0 -127
  44. data/lib/a2a/sse/json_rpc_stream.rb +0 -88
  45. data/lib/a2a/sse/rest_stream.rb +0 -62
  46. data/lib/a2a/sse/stream.rb +0 -257
  47. data/lib/traces/provider/a2a/server/dispatcher.rb +0 -26
  48. /data/lib/a2a/{middleware.rb → server/middleware.rb} +0 -0
  49. /data/lib/a2a/{sse.rb → server/sse.rb} +0 -0
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+ require "a2a/server/sse"
6
+ require "async"
7
+
8
+ module A2A
9
+ class Server
10
+ module Middleware
11
+ # Sets up an SSE stream builder on `env["a2a.stream"]`.
12
+ #
13
+ # The builder detects the protocol binding (REST vs JSON-RPC) from
14
+ # env and creates the correct stream subclass when `open` is called.
15
+ # The `open` block runs inside an Async fiber and the stream is
16
+ # automatically finished when the block exits (even on exception).
17
+ #
18
+ # If the agent never calls `open`, the builder is removed from env
19
+ # so the binding layer doesn't mistake it for a real stream.
20
+ #
21
+ # Part of the A2A::Server middleware stack, so the agent gets
22
+ # env["a2a.stream"] without declaring anything.
23
+ #
24
+ # Usage:
25
+ #
26
+ # case env["a2a.operation"]
27
+ # in "SendStreamingMessage"
28
+ # env["a2a.stream"].open(task_id: "t1", context_id: "c1") do |s|
29
+ # s.task(status: { state: "TASK_STATE_WORKING" })
30
+ # s.status_update(status: { state: "TASK_STATE_COMPLETED" })
31
+ # end
32
+ # end
33
+ #
34
+ class SSEStream
35
+ def initialize(app)
36
+ @app = app
37
+ end
38
+
39
+ def call(env)
40
+ builder = StreamBuilder.new(env)
41
+ env["a2a.stream"] = builder
42
+
43
+ result = @app.call(env)
44
+
45
+ # If open was never called, clear the builder so the binding
46
+ # layer doesn't mistake it for a real stream.
47
+ env.delete("a2a.stream") if env["a2a.stream"].equal?(builder)
48
+
49
+ result
50
+ end
51
+ end
52
+
53
+ # Factory that creates the correct SSE stream subclass based on the
54
+ # protocol binding, then runs the caller's block inside Async with
55
+ # automatic finish on exit.
56
+ #
57
+ # Created by SSEStream middleware — not intended for direct use.
58
+ #
59
+ class StreamBuilder
60
+ def initialize(env)
61
+ @env = env
62
+ end
63
+
64
+ # Create and open an SSE stream for the current request.
65
+ #
66
+ # Detects REST vs JSON-RPC from the env, constructs the correct
67
+ # stream subclass, and yields it to the block. The block runs
68
+ # inside an Async fiber. The stream is automatically finished
69
+ # when the block exits, even if an exception is raised.
70
+ #
71
+ # @param task_id [String] the task identifier for this stream
72
+ # @param context_id [String] the context identifier for this stream
73
+ # @yieldparam stream [A2A::Server::SSE::Stream] the opened stream
74
+ # @return [nil]
75
+ #
76
+ def open(task_id:, context_id:, &block)
77
+ stream = if @env["a2a.json_rpc_id"]
78
+ A2A::Server::SSE::JsonRpcStream.new(
79
+ task_id: task_id, context_id: context_id,
80
+ json_rpc_id: @env["a2a.json_rpc_id"]
81
+ )
82
+ else
83
+ A2A::Server::SSE::RestStream.new(
84
+ task_id: task_id, context_id: context_id
85
+ )
86
+ end
87
+
88
+ @env["a2a.stream"] = stream
89
+
90
+ Async do
91
+ block.call(stream)
92
+ ensure
93
+ stream.finish
94
+ end
95
+
96
+ nil
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ __END__
104
+ describe "A2A::Server::Middleware::SSEStream" do
105
+ it "sets a StreamBuilder on env[\"a2a.stream\"]" do
106
+ seen_stream = nil
107
+ downstream = ->(env) { seen_stream = env["a2a.stream"]; :ok }
108
+
109
+ mw = A2A::Server::Middleware::SSEStream.new(downstream)
110
+ env = {}
111
+ mw.call(env)
112
+
113
+ seen_stream.should.be.kind_of(A2A::Server::Middleware::StreamBuilder)
114
+ end
115
+
116
+ it "auto-clears builder if open was never called" do
117
+ downstream = ->(env) { :ok }
118
+
119
+ mw = A2A::Server::Middleware::SSEStream.new(downstream)
120
+ env = {}
121
+ mw.call(env)
122
+
123
+ env.key?("a2a.stream").should == false
124
+ end
125
+
126
+ it "preserves env[\"a2a.stream\"] when open was called" do
127
+ downstream = ->(env) {
128
+ env["a2a.stream"].open(task_id: "t1", context_id: "c1") do |s|
129
+ # no-op
130
+ end
131
+ }
132
+
133
+ mw = A2A::Server::Middleware::SSEStream.new(downstream)
134
+ env = {}
135
+ mw.call(env)
136
+
137
+ env["a2a.stream"].should.be.kind_of(A2A::Server::SSE::RestStream)
138
+ end
139
+
140
+ it "returns the downstream result" do
141
+ downstream = ->(env) { :result_value }
142
+
143
+ mw = A2A::Server::Middleware::SSEStream.new(downstream)
144
+ result = mw.call({})
145
+
146
+ result.should == :result_value
147
+ end
148
+ end
149
+
150
+ describe "A2A::Server::Middleware::StreamBuilder" do
151
+ it "creates a RestStream when no JSON-RPC ID" do
152
+ env = {}
153
+ builder = A2A::Server::Middleware::StreamBuilder.new(env)
154
+
155
+ builder.open(task_id: "t1", context_id: "c1") do |s|
156
+ s.should.be.kind_of(A2A::Server::SSE::RestStream)
157
+ end
158
+
159
+ env["a2a.stream"].should.be.kind_of(A2A::Server::SSE::RestStream)
160
+ end
161
+
162
+ it "creates a JsonRpcStream when JSON-RPC ID is present" do
163
+ env = { "a2a.json_rpc_id" => 42 }
164
+ builder = A2A::Server::Middleware::StreamBuilder.new(env)
165
+
166
+ builder.open(task_id: "t1", context_id: "c1") do |s|
167
+ s.should.be.kind_of(A2A::Server::SSE::JsonRpcStream)
168
+ end
169
+
170
+ env["a2a.stream"].should.be.kind_of(A2A::Server::SSE::JsonRpcStream)
171
+ end
172
+
173
+ it "passes task_id and context_id to the stream" do
174
+ env = {}
175
+ builder = A2A::Server::Middleware::StreamBuilder.new(env)
176
+
177
+ builder.open(task_id: "t1", context_id: "c1") do |s|
178
+ s.task_id.should == "t1"
179
+ s.context_id.should == "c1"
180
+ end
181
+ end
182
+
183
+ it "auto-finishes the stream when block completes" do
184
+ env = {}
185
+ builder = A2A::Server::Middleware::StreamBuilder.new(env)
186
+
187
+ builder.open(task_id: "t1", context_id: "c1") do |s|
188
+ s.task(status: { state: "TASK_STATE_WORKING" })
189
+ end
190
+
191
+ stream = env["a2a.stream"]
192
+ # After finish, read drains remaining data then returns nil
193
+ stream.read # the task event
194
+ stream.read.should.be.nil
195
+ end
196
+
197
+ it "auto-finishes even when block raises" do
198
+ env = {}
199
+ builder = A2A::Server::Middleware::StreamBuilder.new(env)
200
+
201
+ builder.open(task_id: "t1", context_id: "c1") do |s|
202
+ s.task(status: { state: "TASK_STATE_WORKING" })
203
+ raise "boom"
204
+ end
205
+
206
+ stream = env["a2a.stream"]
207
+ stream.read # the task event
208
+ stream.read.should.be.nil
209
+ end
210
+
211
+ it "returns nil" do
212
+ env = {}
213
+ builder = A2A::Server::Middleware::StreamBuilder.new(env)
214
+
215
+ result = builder.open(task_id: "t1", context_id: "c1") do |s|
216
+ # no-op
217
+ end
218
+
219
+ result.should.be.nil
220
+ end
221
+
222
+ it "typed methods inject task_id and context_id" do
223
+ env = {}
224
+ builder = A2A::Server::Middleware::StreamBuilder.new(env)
225
+
226
+ builder.open(task_id: "t1", context_id: "c1") do |s|
227
+ s.status_update(status: { state: "TASK_STATE_COMPLETED", timestamp: "2025-01-01T00:00:00Z" })
228
+ end
229
+
230
+ stream = env["a2a.stream"]
231
+ chunk = stream.read
232
+ parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
233
+ parsed["statusUpdate"]["taskId"].should == "t1"
234
+ parsed["statusUpdate"]["contextId"].should == "c1"
235
+ end
236
+ end
@@ -3,80 +3,82 @@
3
3
  require "json"
4
4
 
5
5
  module A2A
6
- module SSE
7
- # Parses raw SSE text chunks into typed StreamResponse schema objects.
8
- #
9
- # Faraday's `on_data` callback delivers raw text in arbitrary-sized
10
- # chunks. This parser buffers them, extracts complete SSE events
11
- # (delimited by blank lines), parses the JSON from `data:` lines,
12
- # and yields A2A::Schema["Stream Response"] instances.
13
- #
14
- # For the JSON-RPC binding, the parser unwraps the JSON-RPC 2.0
15
- # envelope (`{"jsonrpc":"2.0","id":N,"result":{...}}`) to extract
16
- # the StreamResponse payload from `result`.
17
- #
18
- # Usage:
19
- #
20
- # parser = A2A::SSE::EventParser.new(binding: :rest)
21
- #
22
- # parser.feed("data: {\"task\":{\"id\":\"t1\"}}\n\n") do |event|
23
- # event.task.id #=> "t1"
24
- # end
25
- #
26
- class EventParser
27
- def initialize(binding:)
28
- @binding = binding
29
- @buffer = String.new
30
- end
31
-
32
- # Feed a raw chunk of SSE text. Yields a StreamResponse for each
33
- # complete event found in the buffer.
6
+ class Server
7
+ module SSE
8
+ # Parses raw SSE text chunks into typed StreamResponse schema objects.
9
+ #
10
+ # Faraday's `on_data` callback delivers raw text in arbitrary-sized
11
+ # chunks. This parser buffers them, extracts complete SSE events
12
+ # (delimited by blank lines), parses the JSON from `data:` lines,
13
+ # and yields A2A::Protocol::JsonSchema["Stream Response"] instances.
14
+ #
15
+ # For the JSON-RPC binding, the parser unwraps the JSON-RPC 2.0
16
+ # envelope (`{"jsonrpc":"2.0","id":N,"result":{...}}`) to extract
17
+ # the StreamResponse payload from `result`.
34
18
  #
35
- # @param chunk [String] raw SSE text from the wire
36
- # @yieldparam event [A2A::Schema::Definition] a Stream Response instance
19
+ # Usage:
37
20
  #
38
- def feed(chunk)
39
- @buffer << chunk
40
-
41
- # SSE events are terminated by a blank line (\n\n).
42
- # We split on that boundary, keeping any incomplete trailing data
43
- # in the buffer for the next call.
44
- while (idx = @buffer.index("\n\n"))
45
- raw_event = @buffer.slice!(0, idx + 2)
46
- payload = parse_event(raw_event)
47
- yield A2A::Schema["Stream Response"].new(payload) if payload
21
+ # parser = A2A::Server::SSE::EventParser.new(binding: :rest)
22
+ #
23
+ # parser.feed("data: {\"task\":{\"id\":\"t1\"}}\n\n") do |event|
24
+ # event.task.id #=> "t1"
25
+ # end
26
+ #
27
+ class EventParser
28
+ def initialize(binding:)
29
+ @binding = binding
30
+ @buffer = String.new
48
31
  end
49
- end
50
32
 
51
- private
33
+ # Feed a raw chunk of SSE text. Yields a StreamResponse for each
34
+ # complete event found in the buffer.
35
+ #
36
+ # @param chunk [String] raw SSE text from the wire
37
+ # @yieldparam event [A2A::Protocol::JsonSchema::Definition] a Stream Response instance
38
+ #
39
+ def feed(chunk)
40
+ @buffer << chunk
41
+
42
+ # SSE events are terminated by a blank line (\n\n).
43
+ # We split on that boundary, keeping any incomplete trailing data
44
+ # in the buffer for the next call.
45
+ while (idx = @buffer.index("\n\n"))
46
+ raw_event = @buffer.slice!(0, idx + 2)
47
+ payload = parse_event(raw_event)
48
+ yield A2A::Protocol::JsonSchema["Stream Response"].new(payload) if payload
49
+ end
50
+ end
51
+
52
+ private
52
53
 
53
- # Extract JSON from `data:` lines and optionally unwrap JSON-RPC.
54
- def parse_event(raw)
55
- data_lines = raw.each_line
56
- .select { |line| line.start_with?("data:") }
57
- .map { |line| line.sub(/\Adata:\s?/, "").chomp }
54
+ # Extract JSON from `data:` lines and optionally unwrap JSON-RPC.
55
+ def parse_event(raw)
56
+ data_lines = raw.each_line
57
+ .select { |line| line.start_with?("data:") }
58
+ .map { |line| line.sub(/\Adata:\s?/, "").chomp }
58
59
 
59
- return nil if data_lines.empty?
60
+ return nil if data_lines.empty?
60
61
 
61
- parsed = JSON.parse(data_lines.join("\n"))
62
+ parsed = JSON.parse(data_lines.join("\n"))
62
63
 
63
- case @binding
64
- when :json_rpc then parsed["result"]
65
- else parsed
64
+ case @binding
65
+ when :json_rpc then parsed["result"]
66
+ else parsed
67
+ end
68
+ rescue JSON::ParserError
69
+ nil
66
70
  end
67
- rescue JSON::ParserError
68
- nil
69
- end
71
+ end
70
72
  end
71
- end
73
+ end
72
74
  end
73
75
 
74
- test do
75
- describe "A2A::SSE::EventParser" do
76
+ __END__
77
+ describe "A2A::Server::SSE::EventParser" do
76
78
  # --- REST binding ---
77
79
 
78
80
  it "parses a single complete SSE event" do
79
- parser = A2A::SSE::EventParser.new(binding: :rest)
81
+ parser = A2A::Server::SSE::EventParser.new(binding: :rest)
80
82
  events = []
81
83
 
82
84
  parser.feed("data: {\"task\":{\"id\":\"t1\",\"contextId\":\"c1\"}}\n\n") do |event|
@@ -84,13 +86,13 @@ test do
84
86
  end
85
87
 
86
88
  events.size.should == 1
87
- events[0].should.be.kind_of(A2A::Schema::Definition)
89
+ events[0].should.be.kind_of(A2A::Protocol::JsonSchema::Definition)
88
90
  events[0].task.should.not.be.nil
89
91
  events[0].task.id.should == "t1"
90
92
  end
91
93
 
92
94
  it "parses multiple events in a single chunk" do
93
- parser = A2A::SSE::EventParser.new(binding: :rest)
95
+ parser = A2A::Server::SSE::EventParser.new(binding: :rest)
94
96
  events = []
95
97
 
96
98
  chunk = [
@@ -106,7 +108,7 @@ test do
106
108
  end
107
109
 
108
110
  it "handles partial chunks via buffering" do
109
- parser = A2A::SSE::EventParser.new(binding: :rest)
111
+ parser = A2A::Server::SSE::EventParser.new(binding: :rest)
110
112
  events = []
111
113
 
112
114
  # First chunk: incomplete event
@@ -120,7 +122,7 @@ test do
120
122
  end
121
123
 
122
124
  it "handles events with event: and id: fields" do
123
- parser = A2A::SSE::EventParser.new(binding: :rest)
125
+ parser = A2A::Server::SSE::EventParser.new(binding: :rest)
124
126
  events = []
125
127
 
126
128
  chunk = "event: message\nid: 42\ndata: {\"task\":{\"id\":\"t1\",\"contextId\":\"c1\"}}\n\n"
@@ -131,7 +133,7 @@ test do
131
133
  end
132
134
 
133
135
  it "skips events with no data lines" do
134
- parser = A2A::SSE::EventParser.new(binding: :rest)
136
+ parser = A2A::Server::SSE::EventParser.new(binding: :rest)
135
137
  events = []
136
138
 
137
139
  parser.feed(": comment\n\n") { |e| events << e }
@@ -139,7 +141,7 @@ test do
139
141
  end
140
142
 
141
143
  it "skips events with invalid JSON" do
142
- parser = A2A::SSE::EventParser.new(binding: :rest)
144
+ parser = A2A::Server::SSE::EventParser.new(binding: :rest)
143
145
  events = []
144
146
 
145
147
  parser.feed("data: not-json\n\n") { |e| events << e }
@@ -149,7 +151,7 @@ test do
149
151
  # --- JSON-RPC binding ---
150
152
 
151
153
  it "unwraps JSON-RPC 2.0 envelope" do
152
- parser = A2A::SSE::EventParser.new(binding: :json_rpc)
154
+ parser = A2A::Server::SSE::EventParser.new(binding: :json_rpc)
153
155
  events = []
154
156
 
155
157
  envelope = {
@@ -168,7 +170,7 @@ test do
168
170
  end
169
171
 
170
172
  it "handles multiple JSON-RPC events" do
171
- parser = A2A::SSE::EventParser.new(binding: :json_rpc)
173
+ parser = A2A::Server::SSE::EventParser.new(binding: :json_rpc)
172
174
  events = []
173
175
 
174
176
  [
@@ -186,7 +188,7 @@ test do
186
188
  # --- Multi-line data ---
187
189
 
188
190
  it "handles multi-line data fields" do
189
- parser = A2A::SSE::EventParser.new(binding: :rest)
191
+ parser = A2A::Server::SSE::EventParser.new(binding: :rest)
190
192
  events = []
191
193
 
192
194
  # JSON split across multiple data: lines
@@ -199,4 +201,3 @@ test do
199
201
  events[0].task.id.should == "t1"
200
202
  end
201
203
  end
202
- end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stream"
4
+
5
+ module A2A
6
+ class Server
7
+ module SSE
8
+ # SSE stream that wraps each event in a JSON-RPC 2.0 response envelope.
9
+ #
10
+ # Per the A2A spec, JSON-RPC streaming returns SSE where each `data:` line
11
+ # is a full JSON-RPC response: {"jsonrpc":"2.0","id":N,"result":{...}}
12
+ #
13
+ # Usage:
14
+ #
15
+ # stream = A2A::Server::SSE::JsonRpcStream.new(
16
+ # task_id: "t1", context_id: "c1", json_rpc_id: 1
17
+ # )
18
+ #
19
+ # Async do
20
+ # stream.task(status: { state: "TASK_STATE_WORKING", timestamp: "..." })
21
+ # stream.finish
22
+ # end
23
+ #
24
+ # [200, A2A::Server::SSE::Stream.headers, stream]
25
+ #
26
+ class JsonRpcStream < Stream
27
+ def initialize(json_rpc_id:, **options)
28
+ @json_rpc_id = json_rpc_id
29
+ super(**options)
30
+ end
31
+
32
+ # Emit an SSE event wrapped in a JSON-RPC envelope.
33
+ #
34
+ # @param data [Hash] the StreamResponse payload (becomes "result")
35
+ #
36
+ def event(data, **opts)
37
+ envelope = {
38
+ "jsonrpc" => "2.0",
39
+ "id" => @json_rpc_id,
40
+ "result" => data.respond_to?(:to_h) ? data.to_h : data,
41
+ }
42
+ super(envelope, **opts)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ __END__
50
+ describe "A2A::Server::SSE::JsonRpcStream" do
51
+ it "wraps events in JSON-RPC 2.0 envelopes" do
52
+ stream = A2A::Server::SSE::JsonRpcStream.new(
53
+ task_id: "t1", context_id: "c1", json_rpc_id: 42
54
+ )
55
+
56
+ stream.task(status: { state: "TASK_STATE_WORKING", timestamp: "2025-01-01T00:00:00Z" })
57
+ stream.finish
58
+
59
+ chunk = stream.read
60
+ chunk.should.include('"jsonrpc"')
61
+ chunk.should.include('"2.0"')
62
+ chunk.should.include('"id":42') # numeric id preserved
63
+ chunk.should.include('"result"')
64
+ end
65
+
66
+ it "is a subclass of SSE::Stream" do
67
+ stream = A2A::Server::SSE::JsonRpcStream.new(
68
+ task_id: "t1", context_id: "c1", json_rpc_id: 1
69
+ )
70
+ stream.is_a?(A2A::Server::SSE::Stream).should == true
71
+ stream.is_a?(::Protocol::HTTP::Body::Readable).should == true
72
+ end
73
+
74
+ it "typed methods chain through JSON-RPC envelope" do
75
+ stream = A2A::Server::SSE::JsonRpcStream.new(
76
+ task_id: "t1", context_id: "c1", json_rpc_id: 7
77
+ )
78
+
79
+ stream.status_update(status: { state: "TASK_STATE_COMPLETED", timestamp: "2025-01-01T00:00:00Z" })
80
+ stream.finish
81
+
82
+ chunk = stream.read
83
+ parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
84
+ parsed["jsonrpc"].should == "2.0"
85
+ parsed["id"].should == 7
86
+ parsed["result"]["statusUpdate"]["taskId"].should == "t1"
87
+ parsed["result"]["statusUpdate"]["contextId"].should == "c1"
88
+ end
89
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stream"
4
+
5
+ module A2A
6
+ class Server
7
+ module SSE
8
+ # SSE stream for the HTTP+JSON/REST binding.
9
+ #
10
+ # Per the A2A spec, REST streaming returns SSE where each `data:` line
11
+ # is a bare StreamResponse JSON object (no envelope wrapping).
12
+ #
13
+ # This is essentially just an alias for Stream — the base class already
14
+ # emits bare JSON. We keep this as a named class for symmetry with
15
+ # JsonRpcStream and to make binding code self-documenting.
16
+ #
17
+ # Usage:
18
+ #
19
+ # stream = A2A::Server::SSE::RestStream.new(task_id: "t1", context_id: "c1")
20
+ #
21
+ # Async do
22
+ # stream.task(status: { state: "TASK_STATE_WORKING", timestamp: "..." })
23
+ # stream.finish
24
+ # end
25
+ #
26
+ # [200, A2A::Server::SSE::Stream.headers, stream]
27
+ #
28
+ class RestStream < Stream
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ __END__
35
+ describe "A2A::Server::SSE::RestStream" do
36
+ it "emits bare JSON events (no envelope)" do
37
+ stream = A2A::Server::SSE::RestStream.new(task_id: "t1", context_id: "c1")
38
+
39
+ stream.status_update(status: { state: "TASK_STATE_COMPLETED", timestamp: "2025-01-01T00:00:00Z" })
40
+ stream.finish
41
+
42
+ chunk = stream.read
43
+ chunk.should.include('"statusUpdate"')
44
+ chunk.should.not.include('"jsonrpc"')
45
+ end
46
+
47
+ it "is a subclass of SSE::Stream" do
48
+ stream = A2A::Server::SSE::RestStream.new(task_id: "t1", context_id: "c1")
49
+ stream.is_a?(A2A::Server::SSE::Stream).should == true
50
+ end
51
+
52
+ it "injects task_id and context_id into typed events" do
53
+ stream = A2A::Server::SSE::RestStream.new(task_id: "t1", context_id: "c1")
54
+
55
+ stream.task(status: { state: "TASK_STATE_WORKING" })
56
+ stream.finish
57
+
58
+ chunk = stream.read
59
+ parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
60
+ parsed["task"]["id"].should == "t1"
61
+ parsed["task"]["contextId"].should == "c1"
62
+ end
63
+ end