ask-session-protocol 0.2.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.
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module SessionProtocol
5
+ # Resolvable interactions — the human-in-the-loop surface of the
6
+ # protocol.
7
+ #
8
+ # An interaction is created by the host (emitted as an interaction
9
+ # event: approval.required, plan.proposed) with a unique id. Any client
10
+ # may resolve it by id through the interaction/* and plan/* methods;
11
+ # the host routes the resolution to the session and tombstones delivery
12
+ # so each subscriber sees it exactly once.
13
+ #
14
+ # approval.required ──▶ interaction/approve | interaction/reject
15
+ # plan.proposed ──▶ plan/approve | plan/reject
16
+ # user input ──▶ interaction/respond (host → client request)
17
+ module Interactions
18
+ # The interaction kinds.
19
+ KINDS = %w[approval plan user_input].freeze
20
+
21
+ # Resolution statuses. A resolved interaction never returns to pending.
22
+ STATUSES = %w[pending approved rejected responded expired].freeze
23
+
24
+ # An interaction on the wire. Immutable.
25
+ #
26
+ # { "id" => "act_123", "kind" => "approval",
27
+ # "status" => "pending", "payload" => { "toolName" => "bash", ... } }
28
+ Interaction = Data.define(:id, :kind, :status, :payload) do
29
+ def initialize(id:, kind:, status: "pending", payload: {})
30
+ unless id.is_a?(String) && !id.empty?
31
+ raise ArgumentError, "interaction id must be a non-empty String"
32
+ end
33
+ unless KINDS.include?(kind)
34
+ raise ArgumentError, "unknown interaction kind: #{kind.inspect} (expected one of #{KINDS.inspect})"
35
+ end
36
+ unless STATUSES.include?(status)
37
+ raise ArgumentError, "unknown interaction status: #{status.inspect} (expected one of #{STATUSES.inspect})"
38
+ end
39
+ raise ArgumentError, "interaction payload must be a Hash" unless payload.is_a?(Hash)
40
+
41
+ super
42
+ end
43
+
44
+ def pending?
45
+ status == "pending"
46
+ end
47
+
48
+ def resolved?
49
+ status != "pending"
50
+ end
51
+
52
+ def to_h
53
+ { "id" => id, "kind" => kind, "status" => status, "payload" => payload }
54
+ end
55
+ end
56
+
57
+ # ── Kind payload shapes ────────────────────────────────────────────
58
+
59
+ # Payload shape for kind "approval" (from the approval.required event).
60
+ APPROVAL_PAYLOAD = {
61
+ "toolName" => { type: :string, required: true },
62
+ "args" => { type: :any, required: false },
63
+ "message" => { type: :string, required: false },
64
+ "autoApprovable" => { type: :boolean, required: false }
65
+ }.freeze
66
+
67
+ # Payload shape for kind "plan" (from the plan.proposed event).
68
+ PLAN_PAYLOAD = {
69
+ "plan" => { type: :string, required: true }
70
+ }.freeze
71
+
72
+ # Payload shape for kind "user_input" (host → client request asking
73
+ # the human for input; resolve via interaction/respond).
74
+ USER_INPUT_PAYLOAD = {
75
+ "prompt" => { type: :string, required: true, description: "What the agent is asking the human." },
76
+ "options" => { type: :array, required: false, description: "Suggested answers, when the host offers any." }
77
+ }.freeze
78
+
79
+ # Payload spec per kind. The single source of truth for interaction
80
+ # payload validation and schema generation.
81
+ PAYLOADS = {
82
+ "approval" => APPROVAL_PAYLOAD,
83
+ "plan" => PLAN_PAYLOAD,
84
+ "user_input" => USER_INPUT_PAYLOAD
85
+ }.freeze
86
+
87
+ # Build an interaction, validating the payload against its kind.
88
+ #
89
+ # @param id [String] unique interaction id
90
+ # @param kind [String] one of KINDS
91
+ # @param status [String] one of STATUSES
92
+ # @param payload [Hash] kind-specific body
93
+ # @return [Interaction]
94
+ def self.interaction(id:, kind:, status: "pending", payload: {})
95
+ validate_payload!(kind, payload)
96
+ Interaction.new(id: id, kind: kind, status: status, payload: payload)
97
+ end
98
+
99
+ # Rebuild an interaction from its wire shape.
100
+ #
101
+ # @param hash [Hash] { "id" =>, "kind" =>, "status" =>, "payload" => }
102
+ # @return [Interaction]
103
+ def self.from_h(hash)
104
+ hash = hash.transform_keys(&:to_s)
105
+ raise ArgumentError, "interaction must be a Hash" unless hash.is_a?(Hash)
106
+
107
+ interaction(
108
+ id: hash["id"],
109
+ kind: hash["kind"],
110
+ status: hash["status"] || "pending",
111
+ payload: hash["payload"] || {}
112
+ )
113
+ end
114
+
115
+ # Validate a payload against its kind spec. Unknown extra fields are
116
+ # allowed (forward compatibility).
117
+ #
118
+ # @param kind [String] one of KINDS
119
+ # @param payload [Hash]
120
+ # @return [true] when valid
121
+ def self.validate_payload!(kind, payload)
122
+ spec = PAYLOADS[kind]
123
+ raise ArgumentError, "unknown interaction kind: #{kind.inspect}" unless spec
124
+ raise ArgumentError, "payload for #{kind} must be a Hash" unless payload.is_a?(Hash)
125
+
126
+ spec.each do |field, field_spec|
127
+ value = payload[field]
128
+ if field_spec[:required] && value.nil?
129
+ raise ArgumentError, "interaction #{kind} missing required payload field #{field.inspect}"
130
+ end
131
+ next if value.nil?
132
+
133
+ Events.validate_field!(kind, field, field_spec, value)
134
+ end
135
+ true
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,393 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module SessionProtocol
5
+ # The RPC surface of the session protocol.
6
+ #
7
+ # Direction conventions (JSON-RPC 2.0 over the host's transport):
8
+ #
9
+ # METHODS — client → host requests (host responds with a result)
10
+ # NOTIFICATIONS — host → client notifications (no response expected):
11
+ # session/event carries the canonical event envelope
12
+ # REQUESTS — host → client reverse requests (client must respond):
13
+ # interaction/requestPermission and
14
+ # interaction/requestUserInput, defined for interop
15
+ # with the external app-server protocol standard.
16
+ # The canonical resolution path is the interaction/*
17
+ # and plan/* METHODS against event-visible interactions.
18
+ #
19
+ # The registry below is the single source of truth for the method
20
+ # surface; the JSON Schema artifact is generated from it, and hosts
21
+ # and clients validate their implementations against it.
22
+ module Methods
23
+ # Host capabilities advertised in the `initialize` result. A client
24
+ # may gate UI features on them.
25
+ CAPABILITIES = %w[
26
+ sessionManagement eventStreaming midExecutionInjection
27
+ interactions planMode todos artifacts workspace fileEvents
28
+ ].freeze
29
+
30
+ # Error codes, JSON-RPC 2.0 reserved range plus session/interaction
31
+ # application codes (matching the app-server protocol convention).
32
+ ERROR_CODES = {
33
+ parse_error: -32700,
34
+ invalid_request: -32600,
35
+ method_not_found: -32601,
36
+ invalid_params: -32602,
37
+ internal_error: -32603,
38
+ session_not_found: -32004,
39
+ session_already_exists: -32005,
40
+ interaction_not_found: -32006,
41
+ plan_not_found: -32007,
42
+ workspace_error: -32008,
43
+ not_implemented: -32009
44
+ }.freeze
45
+
46
+ # Client → host methods with their params and result shapes. Each
47
+ # shape uses the same field-spec language as Events::TYPES.
48
+ METHODS = {
49
+ # ── Handshake ───────────────────────────────────────────────────
50
+ "ping" => {
51
+ description: "Liveness check.",
52
+ params: {},
53
+ result: {
54
+ "status" => { type: :string, required: true },
55
+ "version" => { type: :string, required: true, description: "Gem/host version." },
56
+ "protocolVersion" => { type: :string, required: true, description: "Negotiated wire version." },
57
+ "uptime" => { type: :integer, required: false, description: "Host uptime in seconds." },
58
+ "sessions" => { type: :integer, required: false, description: "Live session count." }
59
+ }
60
+ },
61
+ "initialize" => {
62
+ description: "Handshake: negotiate protocol version and exchange capabilities.",
63
+ params: {
64
+ "client" => { type: :object, required: false, description: "{name, version} of the client." },
65
+ "capabilities" => { type: :array, required: false, description: "Client capabilities (informational)." }
66
+ },
67
+ result: {
68
+ "protocolVersion" => { type: :string, required: true },
69
+ "capabilities" => { type: :array, required: true, description: "Host capabilities (see CAPABILITIES)." },
70
+ "server" => { type: :object, required: true, description: "{name, version} of the host." }
71
+ }
72
+ },
73
+
74
+ # ── Session lifecycle ────────────────────────────────────────────
75
+ "session/create" => {
76
+ description: "Create a session in a workspace.",
77
+ params: {
78
+ "workspace" => { type: :object, required: false, description: "{workspacePath} of the project." },
79
+ "mode" => { type: :string, required: false, description: "Permission mode, e.g. on_request, full_access." },
80
+ "model" => { type: :string, required: false, description: "Model identifier." },
81
+ "tools" => { type: :array, required: false, description: "Tool names to enable." },
82
+ "systemPrompt" => { type: :string, required: false, description: "System prompt override." }
83
+ },
84
+ result: {
85
+ "session" => { type: :object, required: true, description: "{sessionId, model, createdAt}." }
86
+ }
87
+ },
88
+ "session/list" => {
89
+ description: "List sessions, most recent first.",
90
+ params: {
91
+ "limit" => { type: :integer, required: false, description: "Max sessions (default 20)." }
92
+ },
93
+ result: {
94
+ "sessions" => { type: :array, required: true, description: "Session summaries." }
95
+ }
96
+ },
97
+ "session/resume" => {
98
+ description: "Resume/attach to an existing session.",
99
+ params: {
100
+ "sessionId" => { type: :string, required: true }
101
+ },
102
+ result: {
103
+ "sessionId" => { type: :string, required: true },
104
+ "running" => { type: :boolean, required: true, description: "A turn is in progress." },
105
+ "idle" => { type: :boolean, required: true, description: "The session is ready for a prompt." },
106
+ "createdAt" => { type: :string, required: true, description: "ISO 8601 creation time." }
107
+ }
108
+ },
109
+ "session/subscribe" => {
110
+ description: "Subscribe to a session's event stream with replay support.",
111
+ params: {
112
+ "sessionId" => { type: :string, required: true },
113
+ "deliveryKind" => { type: :string, required: false, enum: DELIVERY_KINDS, description: "Default: replay." },
114
+ "afterSeq" => { type: :integer, required: false, description: "Replay events after this seq." },
115
+ "includeSnapshot" => { type: :boolean, required: false, description: "Include a state snapshot in the result." }
116
+ },
117
+ result: {
118
+ "subscription" => { type: :object, required: true, description: "{sessionId, deliveryKind}." },
119
+ "snapshot" => { type: :array, required: false, description: "Events after afterSeq, when includeSnapshot." }
120
+ }
121
+ },
122
+ "session/events" => {
123
+ description: "Poll events after a seq (for clients without a subscription).",
124
+ params: {
125
+ "sessionId" => { type: :string, required: true },
126
+ "afterSeq" => { type: :integer, required: false, description: "Default 0." },
127
+ "limit" => { type: :integer, required: false, description: "Max events to return." }
128
+ },
129
+ result: {
130
+ "events" => { type: :array, required: true, description: "Canonical event envelopes." }
131
+ }
132
+ },
133
+ "session/send" => {
134
+ description: "Inject a message mid-run (barge-in) or prompt an idle session.",
135
+ params: {
136
+ "sessionId" => { type: :string, required: true },
137
+ "content" => { type: :string, required: true, description: "The message text." },
138
+ "expectedTurnId" => { type: :string, required: false, description: "Staleness guard: only steer this turn." }
139
+ },
140
+ result: {
141
+ "accepted" => { type: :boolean, required: true },
142
+ "status" => { type: :string, required: false, enum: %w[queued steered stale], description: "steered: injected now; queued: next turn; stale: turn mismatch." },
143
+ "turnId" => { type: :string, required: false, description: "The turn the message applies to." }
144
+ }
145
+ },
146
+ "session/abort" => {
147
+ description: "Abort the running turn.",
148
+ params: {
149
+ "sessionId" => { type: :string, required: true }
150
+ },
151
+ result: {
152
+ "aborted" => { type: :boolean, required: true },
153
+ "sessionId" => { type: :string, required: true }
154
+ }
155
+ },
156
+ "session/close" => {
157
+ description: "Close the session and release its resources.",
158
+ params: {
159
+ "sessionId" => { type: :string, required: true }
160
+ },
161
+ result: {
162
+ "closed" => { type: :boolean, required: true },
163
+ "sessionId" => { type: :string, required: true }
164
+ }
165
+ },
166
+
167
+ # ── Artifacts ────────────────────────────────────────────────────
168
+ "session/artifacts" => {
169
+ description: "List the session's tool artifacts.",
170
+ params: {
171
+ "sessionId" => { type: :string, required: true }
172
+ },
173
+ result: {
174
+ "artifacts" => { type: :array, required: true, description: "Artifact summaries." }
175
+ }
176
+ },
177
+ "session/artifact/get" => {
178
+ description: "Fetch one artifact's content.",
179
+ params: {
180
+ "sessionId" => { type: :string, required: true },
181
+ "artifactId" => { type: :string, required: true }
182
+ },
183
+ result: {
184
+ "artifact" => { type: :object, required: true, description: "Artifact record with content or uri." }
185
+ }
186
+ },
187
+
188
+ # ── Interactions (approval, user input) ──────────────────────────
189
+ "interaction/list" => {
190
+ description: "List pending interactions for a session.",
191
+ params: {
192
+ "sessionId" => { type: :string, required: true }
193
+ },
194
+ result: {
195
+ "interactions" => { type: :array, required: true, description: "Pending interaction records." }
196
+ }
197
+ },
198
+ "interaction/approve" => {
199
+ description: "Approve a pending interaction by id (approval.required).",
200
+ params: {
201
+ "sessionId" => { type: :string, required: true },
202
+ "interactionId" => { type: :string, required: true }
203
+ },
204
+ result: {
205
+ "approved" => { type: :boolean, required: true },
206
+ "interactionId" => { type: :string, required: true }
207
+ }
208
+ },
209
+ "interaction/reject" => {
210
+ description: "Reject a pending interaction by id (approval.required).",
211
+ params: {
212
+ "sessionId" => { type: :string, required: true },
213
+ "interactionId" => { type: :string, required: true }
214
+ },
215
+ result: {
216
+ "rejected" => { type: :boolean, required: true },
217
+ "interactionId" => { type: :string, required: true }
218
+ }
219
+ },
220
+ "interaction/approve-all" => {
221
+ description: "Approve every pending approval interaction.",
222
+ params: {
223
+ "sessionId" => { type: :string, required: true }
224
+ },
225
+ result: {
226
+ "approved" => { type: :integer, required: true, description: "Number approved." }
227
+ }
228
+ },
229
+ "interaction/reject-all" => {
230
+ description: "Reject every pending approval interaction.",
231
+ params: {
232
+ "sessionId" => { type: :string, required: true }
233
+ },
234
+ result: {
235
+ "rejected" => { type: :integer, required: true, description: "Number rejected." }
236
+ }
237
+ },
238
+ "interaction/respond" => {
239
+ description: "Answer a user_input interaction by id (elicitation).",
240
+ params: {
241
+ "sessionId" => { type: :string, required: true },
242
+ "interactionId" => { type: :string, required: true },
243
+ "response" => { type: :string, required: true }
244
+ },
245
+ result: {
246
+ "responded" => { type: :boolean, required: true },
247
+ "interactionId" => { type: :string, required: true }
248
+ }
249
+ },
250
+
251
+ # ── Plan mode ────────────────────────────────────────────────────
252
+ "plan/approve" => {
253
+ description: "Approve the pending plan proposal (plan.proposed).",
254
+ params: {
255
+ "sessionId" => { type: :string, required: true }
256
+ },
257
+ result: {
258
+ "approved" => { type: :boolean, required: true }
259
+ }
260
+ },
261
+ "plan/reject" => {
262
+ description: "Reject the pending plan proposal; the agent stays in plan mode.",
263
+ params: {
264
+ "sessionId" => { type: :string, required: true }
265
+ },
266
+ result: {
267
+ "rejected" => { type: :boolean, required: true }
268
+ }
269
+ },
270
+
271
+ # ── Workspace ────────────────────────────────────────────────────
272
+ "workspace/readState" => {
273
+ description: "Read the host's workspace state (path, git, mode).",
274
+ params: {},
275
+ result: {
276
+ "workspace" => { type: :object, required: true, description: "{path, name, gitBranch, mode}." }
277
+ }
278
+ }
279
+ }.freeze
280
+
281
+ # Host → client notifications (fire-and-forget).
282
+ NOTIFICATIONS = {
283
+ "session/event" => {
284
+ description: "A canonical session event envelope: {type, seq, payload}.",
285
+ params: {
286
+ "event" => { type: :object, required: true, description: "The canonical event envelope." }
287
+ }
288
+ }
289
+ }.freeze
290
+
291
+ # Host → client reverse requests (client must respond) — defined for
292
+ # interop with the external app-server protocol standard. The
293
+ # canonical resolution path is the interaction/* methods.
294
+ REQUESTS = {
295
+ "interaction/requestPermission" => {
296
+ description: "Ask the client to resolve a tool approval (app-server interop).",
297
+ params: {
298
+ "requestId" => { type: :string, required: true },
299
+ "toolName" => { type: :string, required: true },
300
+ "input" => { type: :any, required: false },
301
+ "riskLevel" => { type: :string, required: false, enum: %w[low medium high critical] },
302
+ "reason" => { type: :string, required: false }
303
+ },
304
+ result: {
305
+ "decision" => { type: :string, required: true, enum: %w[allow deny] }
306
+ }
307
+ },
308
+ "interaction/requestUserInput" => {
309
+ description: "Ask the client for free-form user input (app-server interop).",
310
+ params: {
311
+ "requestId" => { type: :string, required: true },
312
+ "prompt" => { type: :string, required: true },
313
+ "options" => { type: :array, required: false }
314
+ },
315
+ result: {
316
+ "response" => { type: :string, required: true }
317
+ }
318
+ }
319
+ }.freeze
320
+
321
+ class << self
322
+ # The client → host method names, in registry order.
323
+ def method_names
324
+ METHODS.keys
325
+ end
326
+
327
+ # Whether `name` is a canonical client → host method.
328
+ def known?(name)
329
+ METHODS.key?(name)
330
+ end
331
+
332
+ # Whether `name` is a host → client notification.
333
+ def notification?(name)
334
+ NOTIFICATIONS.key?(name)
335
+ end
336
+
337
+ # Whether `name` is a host → client reverse request.
338
+ def request?(name)
339
+ REQUESTS.key?(name)
340
+ end
341
+
342
+ # All method-like names across the three surfaces.
343
+ def all_names
344
+ METHODS.keys + NOTIFICATIONS.keys + REQUESTS.keys
345
+ end
346
+
347
+ # Validate a params hash against a method's params spec.
348
+ # Raises ArgumentError on unknown methods, missing required fields,
349
+ # or type/enum mismatches. Unknown extra fields are allowed.
350
+ #
351
+ # @param name [String] client → host method name
352
+ # @param params [Hash]
353
+ # @return [true] when valid
354
+ def validate_params!(name, params)
355
+ spec = METHODS[name]
356
+ raise ArgumentError, "unknown session protocol method: #{name.inspect}" unless spec
357
+ raise ArgumentError, "params for #{name} must be a Hash" unless params.is_a?(Hash)
358
+
359
+ validate_shape!(name, spec[:params], params)
360
+ true
361
+ end
362
+
363
+ # Validate a result hash against a method's result spec.
364
+ #
365
+ # @param name [String] client → host method name
366
+ # @param result [Hash]
367
+ # @return [true] when valid
368
+ def validate_result!(name, result)
369
+ spec = METHODS[name]
370
+ raise ArgumentError, "unknown session protocol method: #{name.inspect}" unless spec
371
+ raise ArgumentError, "result for #{name} must be a Hash" unless result.is_a?(Hash)
372
+
373
+ validate_shape!(name, spec[:result], result)
374
+ true
375
+ end
376
+
377
+ # @api private
378
+ def validate_shape!(name, shape, value)
379
+ shape.each do |field, field_spec|
380
+ field_value = value[field]
381
+ if field_spec[:required] && field_value.nil?
382
+ raise ArgumentError, "method #{name} missing required field #{field.inspect}"
383
+ end
384
+ next if field_value.nil?
385
+
386
+ Events.validate_field!(name, field, field_spec, field_value)
387
+ end
388
+ end
389
+ private :validate_shape!
390
+ end
391
+ end
392
+ end
393
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module SessionProtocol
5
+ # Generates a JSON Schema (draft 2020-12) document from the contract
6
+ # registries (Events::TYPES, Interactions::PAYLOADS, Methods::METHODS).
7
+ #
8
+ # The output is a static artifact (docs/ask-session-protocol.schema.json,
9
+ # regenerated by `rake schema`) so non-Ruby clients — bots, IDEs —
10
+ # can validate against the contract without loading this gem.
11
+ module Schema
12
+ # @return [Hash] the JSON Schema document
13
+ def self.build
14
+ {
15
+ "$schema" => "https://json-schema.org/draft/2020-12/schema",
16
+ "title" => "Ask Session Protocol",
17
+ "description" => "Canonical wire contract for ask agent sessions: " \
18
+ "event vocabulary, interactions, and the RPC method surface. " \
19
+ "See the ask-session-protocol gem for the authoritative registry.",
20
+ "protocolVersion" => Ask::SessionProtocol::PROTOCOL_VERSION,
21
+ "$defs" => {
22
+ "event" => build_event_schema,
23
+ "interaction" => build_interaction_schema,
24
+ "method" => build_method_schema
25
+ }
26
+ }
27
+ end
28
+
29
+ # Convert a registry field spec to a JSON Schema fragment.
30
+ #
31
+ # @param field_spec [Hash] { type:, required:, enum:, description: }
32
+ # @return [Hash]
33
+ def self.field_schema(field_spec)
34
+ schema = {}
35
+ schema["description"] = field_spec[:description] if field_spec[:description]
36
+ schema["enum"] = field_spec[:enum] if field_spec[:enum]
37
+
38
+ case field_spec[:type]
39
+ when :any then schema
40
+ when :number then schema.merge("type" => "number")
41
+ when :object then schema.merge("type" => "object")
42
+ when :array then schema.merge("type" => "array")
43
+ else schema.merge("type" => field_spec[:type].to_s)
44
+ end
45
+ end
46
+
47
+ # Event envelope schema: one schema per canonical event type.
48
+ #
49
+ # @return [Hash] "$defs/event"
50
+ def self.build_event_schema
51
+ Events::TYPES.each_with_object({}) do |(type, spec), out|
52
+ out[type] = {
53
+ "type" => "object",
54
+ "description" => spec[:description],
55
+ "properties" => {
56
+ "type" => { "const" => type },
57
+ "seq" => { "type" => "integer", "minimum" => 1 },
58
+ "payload" => build_payload_schema(spec[:payload])
59
+ },
60
+ "required" => %w[type seq payload],
61
+ "additionalProperties" => false
62
+ }
63
+ out[type]["x-interaction"] = true if spec[:interaction]
64
+ end
65
+ end
66
+
67
+ # Interaction schema per kind.
68
+ #
69
+ # @return [Hash] "$defs/interaction"
70
+ def self.build_interaction_schema
71
+ Interactions::PAYLOADS.each_with_object({}) do |(kind, payload), out|
72
+ out[kind] = {
73
+ "type" => "object",
74
+ "properties" => {
75
+ "id" => { "type" => "string" },
76
+ "kind" => { "const" => kind },
77
+ "status" => { "type" => "string", "enum" => Interactions::STATUSES },
78
+ "payload" => build_payload_schema(payload)
79
+ },
80
+ "required" => %w[id kind status payload],
81
+ "additionalProperties" => false
82
+ }
83
+ end
84
+ end
85
+
86
+ # Method schema per client → host method: params and result.
87
+ #
88
+ # @return [Hash] "$defs/method"
89
+ def self.build_method_schema
90
+ Methods::METHODS.each_with_object({}) do |(name, spec), out|
91
+ out[name] = {
92
+ "type" => "object",
93
+ "description" => spec[:description],
94
+ "properties" => {
95
+ "method" => { "const" => name },
96
+ "params" => build_payload_schema(spec[:params])
97
+ },
98
+ "required" => %w[method params],
99
+ "additionalProperties" => false
100
+ }
101
+ end
102
+ end
103
+
104
+ # @api private
105
+ def self.build_payload_schema(fields)
106
+ properties = fields.each_with_object({}) do |(field, field_spec), out|
107
+ out[field] = field_schema(field_spec)
108
+ end
109
+ required = fields.select { |_field, spec| spec[:required] }.keys
110
+ schema = { "type" => "object", "properties" => properties }
111
+ schema["required"] = required unless required.empty?
112
+ schema
113
+ end
114
+ private_class_method :build_payload_schema
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module SessionProtocol
5
+ VERSION = "0.2.0"
6
+ end
7
+ end