mockserver-client 7.2.0 → 7.3.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.
- checksums.yaml +4 -4
- data/README.md +61 -1
- data/lib/mockserver/a2a.rb +529 -0
- data/lib/mockserver/client.rb +504 -0
- data/lib/mockserver/models.rb +270 -16
- data/lib/mockserver/version.rb +1 -1
- data/lib/mockserver-client.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa28c69e849b63f6fa75a1f57f939a348cf30d3032d6b1dfb040276dbd51eb6b
|
|
4
|
+
data.tar.gz: 188c6469fc18b0ed51b4099a652d68a3b00b0bbccda233265b282e9d08e5c677
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c4d3d24b31236d09f8684b10bc114e6cb2154f532ff230453f9e3f566ecbd271ef4c770a8d3f6db6a2476bd89efdd529471ba4fb46ca2ebe0e89e8a97f2fc63e
|
|
7
|
+
data.tar.gz: daef70802e8ee57033d185f74c580c3b61d87eb97fc0c74ab31288f345e9fe86c9823b6ff6f90231d2066c8b9f05a6446d5ba06a7d917bdb61dd1215ef2094ae
|
data/README.md
CHANGED
|
@@ -183,6 +183,66 @@ MockServer::MCP.mcp_mock('/mcp')
|
|
|
183
183
|
|
|
184
184
|
`build` returns the raw expectation Hashes if you prefer to register them yourself.
|
|
185
185
|
|
|
186
|
+
## A2A Mocking
|
|
187
|
+
|
|
188
|
+
`MockServer::A2A.a2a_mock` builds the set of expectations needed to emulate an
|
|
189
|
+
A2A (Agent-to-Agent) agent: an agent card served at `/.well-known/agent.json`
|
|
190
|
+
plus JSON-RPC 2.0 `tasks/send`, `tasks/get`, and `tasks/cancel` over `POST
|
|
191
|
+
<path>`. It optionally adds SSE streaming, push-notification config + delivery,
|
|
192
|
+
per-message custom task handlers, and advertised skills.
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
MockServer::A2A.a2a_mock('/a2a')
|
|
196
|
+
.with_agent_name('WeatherAgent')
|
|
197
|
+
.with_agent_description('Forecasts the weather')
|
|
198
|
+
.with_skill('weather')
|
|
199
|
+
.with_name('Weather lookup')
|
|
200
|
+
.with_tag('forecast')
|
|
201
|
+
.with_example('What is the weather in Paris?')
|
|
202
|
+
.and_then
|
|
203
|
+
.with_streaming
|
|
204
|
+
.with_push_notifications('http://localhost:1234/a2a/callback')
|
|
205
|
+
.on_task_send
|
|
206
|
+
.matching_message('forecast')
|
|
207
|
+
.responding_with('Sunny, 25C')
|
|
208
|
+
.and_then
|
|
209
|
+
.apply_to(client)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
When push notifications are configured, each `tasks/send` both returns the
|
|
213
|
+
JSON-RPC task response to the caller *and* POSTs the completed task to the
|
|
214
|
+
webhook URL. As with MCP mocking, `build` returns the raw expectation Hashes if
|
|
215
|
+
you prefer to register them yourself.
|
|
216
|
+
|
|
217
|
+
## SRE Control Plane (SLO + Chaos)
|
|
218
|
+
|
|
219
|
+
Verify service-level objectives and drive scheduled chaos experiments against
|
|
220
|
+
the running MockServer:
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
# Evaluate SLOs over a window. Returns the verdict Hash on PASS/INCONCLUSIVE;
|
|
224
|
+
# raises MockServer::VerificationError on FAIL (HTTP 406).
|
|
225
|
+
verdict = client.verify_slo(
|
|
226
|
+
'name' => 'checkout-latency',
|
|
227
|
+
'window' => 'PT5M',
|
|
228
|
+
'minimumSampleCount' => 100,
|
|
229
|
+
'objectives' => [
|
|
230
|
+
{ 'sli' => 'p99_latency_ms', 'comparator' => 'LESS_THAN', 'threshold' => 250 }
|
|
231
|
+
]
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Start a scheduled multi-stage chaos experiment (only one may be active).
|
|
235
|
+
client.start_chaos_experiment(
|
|
236
|
+
'name' => 'payments-brownout',
|
|
237
|
+
'stages' => [
|
|
238
|
+
{ 'durationMillis' => 60_000, 'profiles' => { 'payments.svc' => 'LATENCY' } }
|
|
239
|
+
]
|
|
240
|
+
)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
SLO tracking must be enabled on the server (`sloTrackingEnabled=true`) or
|
|
244
|
+
`verify_slo` raises `MockServer::Error` on HTTP 400.
|
|
245
|
+
|
|
186
246
|
## Interactive Breakpoints
|
|
187
247
|
|
|
188
248
|
The client supports matcher-driven interactive breakpoints over the callback WebSocket. Register a breakpoint matcher to pause forwarded/proxied exchanges at specific phases and inspect/modify/continue them via callback handlers.
|
|
@@ -256,7 +316,7 @@ launcher_path = MockServer::BinaryLauncher.ensure_launcher
|
|
|
256
316
|
### Specify a version
|
|
257
317
|
|
|
258
318
|
```ruby
|
|
259
|
-
handle = MockServer::BinaryLauncher.start(port: 1080, version: '7.
|
|
319
|
+
handle = MockServer::BinaryLauncher.start(port: 1080, version: '7.3.0')
|
|
260
320
|
```
|
|
261
321
|
|
|
262
322
|
### API reference
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module MockServer
|
|
7
|
+
# Fluent builder for mocking an A2A (Agent-to-Agent) agent.
|
|
8
|
+
#
|
|
9
|
+
# Mirrors the Java +A2aMockBuilder+ (and its Node/Python siblings). It produces
|
|
10
|
+
# the same wire-level expectation JSON: a set of HTTP expectations that emulate
|
|
11
|
+
# an A2A agent serving an agent card and speaking JSON-RPC 2.0 over +POST
|
|
12
|
+
# <path>+. The generated control-plane expectations are:
|
|
13
|
+
#
|
|
14
|
+
# * agent-card +GET <agent_card_path>+ (default +/.well-known/agent.json+);
|
|
15
|
+
# * JSON-RPC +tasks/send+, +tasks/get+, +tasks/cancel+;
|
|
16
|
+
# * optional SSE streaming (+with_streaming+);
|
|
17
|
+
# * optional push-notification config + delivery (+with_push_notifications+);
|
|
18
|
+
# * optional per-message custom task handlers (+on_task_send+);
|
|
19
|
+
# * optional advertised skills (+with_skill+).
|
|
20
|
+
#
|
|
21
|
+
# Each JSON-RPC response is a Velocity template that echoes the incoming
|
|
22
|
+
# JSON-RPC id back via +$!{request.jsonRpcRawId}+.
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# MockServer::A2A.a2a_mock('/a2a')
|
|
26
|
+
# .with_agent_name('WeatherAgent')
|
|
27
|
+
# .with_skill('weather')
|
|
28
|
+
# .with_name('Weather lookup')
|
|
29
|
+
# .with_tag('forecast')
|
|
30
|
+
# .and_then
|
|
31
|
+
# .on_task_send
|
|
32
|
+
# .matching_message('forecast')
|
|
33
|
+
# .responding_with('Sunny, 25C')
|
|
34
|
+
# .and_then
|
|
35
|
+
# .apply_to(client)
|
|
36
|
+
module A2A
|
|
37
|
+
# JSON-escape a string the same way Jackson +writeValueAsString+ does, then
|
|
38
|
+
# strip the surrounding quotes — yielding only the escaped inner content.
|
|
39
|
+
# @api private
|
|
40
|
+
def self.escape_json(value)
|
|
41
|
+
return '' if value.nil?
|
|
42
|
+
|
|
43
|
+
quoted = JSON.generate(value.to_s)
|
|
44
|
+
quoted[1...-1]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Escape Velocity metacharacters so literal +$+ / +#+ survive rendering.
|
|
48
|
+
# @api private
|
|
49
|
+
def self.escape_velocity(value)
|
|
50
|
+
return value if value.nil?
|
|
51
|
+
|
|
52
|
+
value.to_s.gsub('$', '${esc.d}').gsub('#', '${esc.h}')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @api private
|
|
56
|
+
def self.velocity_json_rpc_response(result_json)
|
|
57
|
+
'{"statusCode": 200, ' \
|
|
58
|
+
'"headers": [{"name": "Content-Type", "values": ["application/json"]}], ' \
|
|
59
|
+
'"body": {"jsonrpc": "2.0", "result": ' + result_json + ', "id": $!{request.jsonRpcRawId}}}'
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @api private
|
|
63
|
+
# Wraps a Hash so it responds to +to_h+, allowing it to be passed to
|
|
64
|
+
# +Client#upsert+.
|
|
65
|
+
class RawExpectation
|
|
66
|
+
def initialize(hash)
|
|
67
|
+
@hash = hash
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_h
|
|
71
|
+
@hash
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @api private
|
|
76
|
+
SkillDef = Struct.new(:id, :name, :description, :tags, :examples)
|
|
77
|
+
# @api private
|
|
78
|
+
TaskHandler = Struct.new(:message_pattern, :response_text, :is_error)
|
|
79
|
+
|
|
80
|
+
# Resolved components of a push-notification webhook URL.
|
|
81
|
+
# @api private
|
|
82
|
+
WebhookTarget = Struct.new(:host, :port, :secure, :path) do
|
|
83
|
+
def host_header
|
|
84
|
+
"#{host}:#{port}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.parse(url)
|
|
88
|
+
uri = URI.parse(url)
|
|
89
|
+
secure = uri.scheme.to_s.downcase == 'https'
|
|
90
|
+
host = uri.host
|
|
91
|
+
raise ArgumentError, "Invalid push-notification webhook URL (no host): #{url}" if host.nil? || host.empty?
|
|
92
|
+
|
|
93
|
+
port = uri.port || (secure ? 443 : 80)
|
|
94
|
+
path = uri.path
|
|
95
|
+
path = '/' if path.nil? || path.empty?
|
|
96
|
+
new(host, port, secure, path)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
class A2aMockBuilder
|
|
101
|
+
def initialize(path = '/a2a')
|
|
102
|
+
@path = path.is_a?(String) ? path : '/a2a'
|
|
103
|
+
@agent_card_path = '/.well-known/agent.json'
|
|
104
|
+
@agent_name = 'MockAgent'
|
|
105
|
+
@agent_description = 'A mock A2A agent'
|
|
106
|
+
@agent_version = '1.0.0'
|
|
107
|
+
@agent_url = nil
|
|
108
|
+
@skills = []
|
|
109
|
+
@task_handlers = []
|
|
110
|
+
@default_task_response = 'Task completed successfully'
|
|
111
|
+
@streaming = false
|
|
112
|
+
@streaming_method = 'message/stream'
|
|
113
|
+
@push_notification_url = nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [self]
|
|
117
|
+
def with_agent_name(name)
|
|
118
|
+
@agent_name = name
|
|
119
|
+
self
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [self]
|
|
123
|
+
def with_agent_description(description)
|
|
124
|
+
@agent_description = description
|
|
125
|
+
self
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [self]
|
|
129
|
+
def with_agent_version(version)
|
|
130
|
+
@agent_version = version
|
|
131
|
+
self
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @return [self]
|
|
135
|
+
def with_agent_url(url)
|
|
136
|
+
@agent_url = url
|
|
137
|
+
self
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [self]
|
|
141
|
+
def with_agent_card_path(path)
|
|
142
|
+
@agent_card_path = path
|
|
143
|
+
self
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [self]
|
|
147
|
+
def with_default_task_response(response)
|
|
148
|
+
@default_task_response = response
|
|
149
|
+
self
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Advertise + mock the A2A streaming capability. The agent card reports
|
|
153
|
+
# +capabilities.streaming: true+ and the streaming JSON-RPC method (default
|
|
154
|
+
# +message/stream+) returns an SSE stream of status/artifact update events.
|
|
155
|
+
# @return [self]
|
|
156
|
+
def with_streaming
|
|
157
|
+
@streaming = true
|
|
158
|
+
self
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Override the JSON-RPC method that triggers the streaming response.
|
|
162
|
+
# Implies {#with_streaming}.
|
|
163
|
+
# @return [self]
|
|
164
|
+
def with_streaming_method(method)
|
|
165
|
+
@streaming_method = method
|
|
166
|
+
@streaming = true
|
|
167
|
+
self
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Advertise + mock A2A push notifications. The agent card reports
|
|
171
|
+
# +capabilities.pushNotifications: true+, +tasks/pushNotificationConfig/set+
|
|
172
|
+
# echoes the registered config, and each +tasks/send+ additionally POSTs the
|
|
173
|
+
# completed task to +webhook_url+ while still returning the JSON-RPC task
|
|
174
|
+
# response to the caller.
|
|
175
|
+
# @return [self]
|
|
176
|
+
def with_push_notifications(webhook_url)
|
|
177
|
+
@push_notification_url = webhook_url
|
|
178
|
+
self
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @return [A2aSkillBuilder]
|
|
182
|
+
def with_skill(id)
|
|
183
|
+
A2aSkillBuilder.new(self, id)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# @return [A2aTaskHandlerBuilder]
|
|
187
|
+
def on_task_send
|
|
188
|
+
A2aTaskHandlerBuilder.new(self)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# @api private
|
|
192
|
+
def add_skill(skill)
|
|
193
|
+
@skills << skill
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# @api private
|
|
197
|
+
def add_task_handler(handler)
|
|
198
|
+
@task_handlers << handler
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# @return [Array<Hash>] the ordered list of expectations
|
|
202
|
+
def build
|
|
203
|
+
expectations = [build_agent_card_expectation]
|
|
204
|
+
|
|
205
|
+
@task_handlers.each { |handler| expectations << build_custom_task_handler(handler) }
|
|
206
|
+
|
|
207
|
+
expectations << build_streaming_expectation if @streaming
|
|
208
|
+
|
|
209
|
+
if @push_notification_url
|
|
210
|
+
expectations << build_push_notification_config_expectation
|
|
211
|
+
expectations << build_push_notification_delivery_expectation
|
|
212
|
+
else
|
|
213
|
+
expectations << build_tasks_send_expectation
|
|
214
|
+
end
|
|
215
|
+
expectations << build_tasks_get_expectation
|
|
216
|
+
expectations << build_tasks_cancel_expectation
|
|
217
|
+
|
|
218
|
+
expectations
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# @return [Array<Expectation>]
|
|
222
|
+
def apply_to(client)
|
|
223
|
+
client.upsert(*build.map { |h| RawExpectation.new(h) })
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def json_rpc_request(method)
|
|
229
|
+
{ 'method' => 'POST', 'path' => @path, 'body' => { 'type' => 'JSON_RPC', 'method' => method } }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def json_path_request(json_path)
|
|
233
|
+
{ 'method' => 'POST', 'path' => @path, 'body' => { 'type' => 'JSON_PATH', 'jsonPath' => json_path } }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def velocity_template_response_expectation(http_request, result_json)
|
|
237
|
+
{
|
|
238
|
+
'httpRequest' => http_request,
|
|
239
|
+
'httpResponseTemplate' => {
|
|
240
|
+
'template' => A2A.velocity_json_rpc_response(result_json),
|
|
241
|
+
'templateType' => 'VELOCITY'
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def build_agent_card_expectation
|
|
247
|
+
skill_items = @skills.map do |skill|
|
|
248
|
+
parts = ['{"id": "' + esc_plain(skill.id) + '"']
|
|
249
|
+
parts << ', "name": "' + esc_plain(skill.name || skill.id) + '"'
|
|
250
|
+
parts << ', "description": "' + esc_plain(skill.description) + '"' unless skill.description.nil?
|
|
251
|
+
unless skill.tags.empty?
|
|
252
|
+
parts << ', "tags": [' + skill.tags.map { |t| '"' + esc_plain(t) + '"' }.join(', ') + ']'
|
|
253
|
+
end
|
|
254
|
+
unless skill.examples.empty?
|
|
255
|
+
parts << ', "examples": [' + skill.examples.map { |e| '"' + esc_plain(e) + '"' }.join(', ') + ']'
|
|
256
|
+
end
|
|
257
|
+
parts << '}'
|
|
258
|
+
parts.join
|
|
259
|
+
end
|
|
260
|
+
skills_json = '[' + skill_items.join(', ') + ']'
|
|
261
|
+
|
|
262
|
+
url = @agent_url || ('http://localhost' + @path)
|
|
263
|
+
|
|
264
|
+
agent_card_json =
|
|
265
|
+
'{' \
|
|
266
|
+
'"name": "' + esc_plain(@agent_name) + '", ' \
|
|
267
|
+
'"description": "' + esc_plain(@agent_description) + '", ' \
|
|
268
|
+
'"version": "' + esc_plain(@agent_version) + '", ' \
|
|
269
|
+
'"url": "' + esc_plain(url) + '", ' \
|
|
270
|
+
'"capabilities": {"streaming": ' + bool(@streaming) + ', ' \
|
|
271
|
+
'"pushNotifications": ' + bool(!@push_notification_url.nil?) + ', ' \
|
|
272
|
+
'"stateTransitionHistory": false}, ' \
|
|
273
|
+
'"skills": ' + skills_json + '}'
|
|
274
|
+
|
|
275
|
+
{
|
|
276
|
+
'httpRequest' => { 'method' => 'GET', 'path' => @agent_card_path },
|
|
277
|
+
'httpResponse' => {
|
|
278
|
+
'statusCode' => 200,
|
|
279
|
+
'headers' => [{ 'name' => 'Content-Type', 'values' => ['application/json'] }],
|
|
280
|
+
'body' => agent_card_json
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def build_tasks_send_expectation
|
|
286
|
+
velocity_template_response_expectation(
|
|
287
|
+
json_rpc_request('tasks/send'),
|
|
288
|
+
task_result_json(@default_task_response, false)
|
|
289
|
+
)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def build_tasks_get_expectation
|
|
293
|
+
velocity_template_response_expectation(
|
|
294
|
+
json_rpc_request('tasks/get'),
|
|
295
|
+
task_result_json(@default_task_response, false)
|
|
296
|
+
)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def build_tasks_cancel_expectation
|
|
300
|
+
result_json = '{"id": "mock-task-id", "status": {"state": "canceled"}}'
|
|
301
|
+
velocity_template_response_expectation(json_rpc_request('tasks/cancel'), result_json)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def build_custom_task_handler(handler)
|
|
305
|
+
escaped_pattern = escape_message_pattern(handler.message_pattern)
|
|
306
|
+
json_path = "$[?(@.method == 'tasks/send' && @.params.message.parts[0].text =~ /" + escaped_pattern + '/)]'
|
|
307
|
+
velocity_template_response_expectation(
|
|
308
|
+
json_path_request(json_path),
|
|
309
|
+
task_result_json(handler.response_text, handler.is_error)
|
|
310
|
+
)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Neutralize only the regex-delimiter breakout while preserving every
|
|
314
|
+
# existing regex escape sequence. The user-supplied message_pattern is
|
|
315
|
+
# documented as a regular expression and is embedded between `/.../`
|
|
316
|
+
# delimiters inside a JSONPath. A single-pass scanner is required (rather
|
|
317
|
+
# than independent gsubs) so that a backslash and the character it escapes
|
|
318
|
+
# are consumed together — otherwise a trailing lone backslash, or an input
|
|
319
|
+
# containing `\/`, could escape the closing `/` delimiter and break out of
|
|
320
|
+
# the regex literal into the surrounding JSONPath/JSON.
|
|
321
|
+
# (CodeQL rb/incomplete-sanitization, alert #65.)
|
|
322
|
+
def escape_message_pattern(pattern)
|
|
323
|
+
chars = pattern.to_s.chars
|
|
324
|
+
out = +''
|
|
325
|
+
i = 0
|
|
326
|
+
while i < chars.length
|
|
327
|
+
c = chars[i]
|
|
328
|
+
case c
|
|
329
|
+
when '\\'
|
|
330
|
+
if i + 1 < chars.length
|
|
331
|
+
out << '\\' << chars[i + 1] # preserve escape sequence (\d, \/, \\)
|
|
332
|
+
i += 1 # extra advance past the escaped char
|
|
333
|
+
else
|
|
334
|
+
out << '\\\\' # trailing lone backslash -> literal backslash
|
|
335
|
+
end
|
|
336
|
+
when '/' then out << '\\/'
|
|
337
|
+
when "\n" then out << '\\n'
|
|
338
|
+
when "\r" then out << '\\r'
|
|
339
|
+
when "\0" then nil # strip NUL
|
|
340
|
+
else out << c
|
|
341
|
+
end
|
|
342
|
+
i += 1
|
|
343
|
+
end
|
|
344
|
+
out
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def build_streaming_expectation
|
|
348
|
+
text = A2A.escape_json(@default_task_response)
|
|
349
|
+
task_id = 'mock-task-id'
|
|
350
|
+
|
|
351
|
+
events = [
|
|
352
|
+
{ 'event' => 'message',
|
|
353
|
+
'data' => '{"jsonrpc": "2.0", "id": "1", "result": ' \
|
|
354
|
+
'{"taskId": "' + task_id + '", "kind": "status-update", ' \
|
|
355
|
+
'"status": {"state": "working"}, "final": false}}' },
|
|
356
|
+
{ 'event' => 'message',
|
|
357
|
+
'data' => '{"jsonrpc": "2.0", "id": "1", "result": ' \
|
|
358
|
+
'{"taskId": "' + task_id + '", "kind": "artifact-update", ' \
|
|
359
|
+
'"artifact": {"parts": [{"type": "text", "text": "' + text + '"}]}}}' },
|
|
360
|
+
{ 'event' => 'message',
|
|
361
|
+
'data' => '{"jsonrpc": "2.0", "id": "1", "result": ' \
|
|
362
|
+
'{"taskId": "' + task_id + '", "kind": "status-update", ' \
|
|
363
|
+
'"status": {"state": "completed"}, "final": true}}' }
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
{
|
|
367
|
+
'httpRequest' => json_rpc_request(@streaming_method),
|
|
368
|
+
'httpSseResponse' => {
|
|
369
|
+
'statusCode' => 200,
|
|
370
|
+
'events' => events,
|
|
371
|
+
'closeConnection' => true
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def build_push_notification_config_expectation
|
|
377
|
+
# Echo the registered push-notification config back as the JSON-RPC result.
|
|
378
|
+
result_json = '{"url": "' + A2A.escape_velocity(A2A.escape_json(@push_notification_url)) + '"}'
|
|
379
|
+
velocity_template_response_expectation(
|
|
380
|
+
json_rpc_request('tasks/pushNotificationConfig/set'),
|
|
381
|
+
result_json
|
|
382
|
+
)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def build_push_notification_delivery_expectation
|
|
386
|
+
# A tasks/send both returns the JSON-RPC task response to the caller AND
|
|
387
|
+
# POSTs the completed task to the configured webhook. Modelled with an
|
|
388
|
+
# override-forwarded-request: the request override targets the webhook
|
|
389
|
+
# (literal body, JSON-escaped only — no Velocity engine runs over a request
|
|
390
|
+
# override), and a Velocity response *template* produces the caller's
|
|
391
|
+
# JSON-RPC response so the request's id is echoed back.
|
|
392
|
+
target = WebhookTarget.parse(@push_notification_url)
|
|
393
|
+
|
|
394
|
+
push_body = '{"jsonrpc": "2.0", "result": ' + task_result_json_raw(@default_task_response, false) + '}'
|
|
395
|
+
|
|
396
|
+
webhook_request = {
|
|
397
|
+
'method' => 'POST',
|
|
398
|
+
'path' => target.path,
|
|
399
|
+
'socketAddress' => {
|
|
400
|
+
'host' => target.host,
|
|
401
|
+
'port' => target.port,
|
|
402
|
+
'scheme' => target.secure ? 'HTTPS' : 'HTTP'
|
|
403
|
+
},
|
|
404
|
+
'secure' => target.secure,
|
|
405
|
+
'headers' => [
|
|
406
|
+
{ 'name' => 'Host', 'values' => [target.host_header] },
|
|
407
|
+
{ 'name' => 'Content-Type', 'values' => ['application/json'] }
|
|
408
|
+
],
|
|
409
|
+
'body' => push_body
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
{
|
|
413
|
+
'httpRequest' => json_rpc_request('tasks/send'),
|
|
414
|
+
'httpOverrideForwardedRequest' => {
|
|
415
|
+
'requestOverride' => webhook_request,
|
|
416
|
+
'responseTemplate' => {
|
|
417
|
+
'template' => A2A.velocity_json_rpc_response(task_result_json(@default_task_response, false)),
|
|
418
|
+
'templateType' => 'VELOCITY'
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Velocity-templated result body: text must survive the Velocity engine, so
|
|
425
|
+
# both JSON and Velocity escaping are applied (Velocity un-escapes at render).
|
|
426
|
+
def task_result_json(response_text, is_error)
|
|
427
|
+
task_result_json_with_text(A2A.escape_velocity(A2A.escape_json(response_text)), is_error)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Literal (non-templated) result body (e.g. the webhook payload): only JSON
|
|
431
|
+
# escaping — Velocity escaping would corrupt '$' / '#'.
|
|
432
|
+
def task_result_json_raw(response_text, is_error)
|
|
433
|
+
task_result_json_with_text(A2A.escape_json(response_text), is_error)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def task_result_json_with_text(escaped_text, is_error)
|
|
437
|
+
state = is_error ? 'failed' : 'completed'
|
|
438
|
+
'{"id": "mock-task-id", ' \
|
|
439
|
+
'"status": {"state": "' + state + '"}, ' \
|
|
440
|
+
'"artifacts": [{"parts": [{"type": "text", "text": "' + escaped_text + '"}]}]}'
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def bool(value)
|
|
444
|
+
value ? 'true' : 'false'
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# JSON-escape then Velocity-escape, matching the reference clients (used for
|
|
448
|
+
# the agent card, which is a literal — but kept Velocity-safe for parity with
|
|
449
|
+
# the Java builder's escapeJson-only card; here the card is a literal HTTP
|
|
450
|
+
# response body, so only JSON escaping is required).
|
|
451
|
+
def esc_plain(value)
|
|
452
|
+
A2A.escape_json(value)
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
class A2aSkillBuilder
|
|
457
|
+
def initialize(parent, id)
|
|
458
|
+
@parent = parent
|
|
459
|
+
@skill = SkillDef.new(id, nil, nil, [], [])
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# @return [self]
|
|
463
|
+
def with_name(name)
|
|
464
|
+
@skill.name = name
|
|
465
|
+
self
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# @return [self]
|
|
469
|
+
def with_description(description)
|
|
470
|
+
@skill.description = description
|
|
471
|
+
self
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# @return [self]
|
|
475
|
+
def with_tag(tag)
|
|
476
|
+
@skill.tags << tag
|
|
477
|
+
self
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# @return [self]
|
|
481
|
+
def with_example(example)
|
|
482
|
+
@skill.examples << example
|
|
483
|
+
self
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Commit the skill and return to the root builder.
|
|
487
|
+
# @return [A2aMockBuilder]
|
|
488
|
+
def and_then
|
|
489
|
+
@parent.add_skill(@skill)
|
|
490
|
+
@parent
|
|
491
|
+
end
|
|
492
|
+
alias and_ and_then
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
class A2aTaskHandlerBuilder
|
|
496
|
+
def initialize(parent)
|
|
497
|
+
@parent = parent
|
|
498
|
+
@handler = TaskHandler.new('.*', 'Task completed', false)
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# @return [self]
|
|
502
|
+
def matching_message(pattern)
|
|
503
|
+
@handler.message_pattern = pattern
|
|
504
|
+
self
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# @return [self]
|
|
508
|
+
def responding_with(text, is_error = false)
|
|
509
|
+
@handler.response_text = text
|
|
510
|
+
@handler.is_error = is_error
|
|
511
|
+
self
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Commit the handler and return to the root builder.
|
|
515
|
+
# @return [A2aMockBuilder]
|
|
516
|
+
def and_then
|
|
517
|
+
@parent.add_task_handler(@handler)
|
|
518
|
+
@parent
|
|
519
|
+
end
|
|
520
|
+
alias and_ and_then
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# Create a new A2A mock builder. +path+ defaults to +/a2a+.
|
|
524
|
+
# @return [A2aMockBuilder]
|
|
525
|
+
def self.a2a_mock(path = '/a2a')
|
|
526
|
+
A2aMockBuilder.new(path)
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
end
|