agent2agent 1.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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/data/a2a.json +1961 -0
  3. data/data/a2a.proto +796 -0
  4. data/data/download-a2a-resources +4 -0
  5. data/data/spec.txt +3611 -0
  6. data/lib/a2a/agent.rb +243 -0
  7. data/lib/a2a/bindings/json_rpc.rb +110 -0
  8. data/lib/a2a/bindings/rest.rb +106 -0
  9. data/lib/a2a/client.rb +85 -0
  10. data/lib/a2a/proto.rb +444 -0
  11. data/lib/a2a/schema/definition.rb +114 -0
  12. data/lib/a2a/schema/validation_error.rb +129 -0
  13. data/lib/a2a/schema.rb +429 -0
  14. data/lib/a2a/server/cancel_task.rb +14 -0
  15. data/lib/a2a/server/create_task_push_notification_config.rb +14 -0
  16. data/lib/a2a/server/delete_task_push_notification_config.rb +14 -0
  17. data/lib/a2a/server/dispatcher.rb +127 -0
  18. data/lib/a2a/server/env.rb +28 -0
  19. data/lib/a2a/server/get_extended_agent_card.rb +15 -0
  20. data/lib/a2a/server/get_task.rb +14 -0
  21. data/lib/a2a/server/get_task_push_notification_config.rb +14 -0
  22. data/lib/a2a/server/list_task_push_notification_configs.rb +14 -0
  23. data/lib/a2a/server/list_tasks.rb +14 -0
  24. data/lib/a2a/server/send_message.rb +14 -0
  25. data/lib/a2a/server/send_streaming_message.rb +14 -0
  26. data/lib/a2a/server/subscribe_to_task.rb +14 -0
  27. data/lib/a2a/server/triage.rb +96 -0
  28. data/lib/a2a/server.rb +98 -0
  29. data/lib/a2a/sse/json_rpc_stream.rb +66 -0
  30. data/lib/a2a/sse/rest_stream.rb +50 -0
  31. data/lib/a2a/sse/stream.rb +129 -0
  32. data/lib/a2a/sse.rb +5 -0
  33. data/lib/a2a/store/processor.rb +136 -0
  34. data/lib/a2a/store/pub_sub.rb +149 -0
  35. data/lib/a2a/store/sqlite.rb +533 -0
  36. data/lib/a2a/store/webhooks.rb +94 -0
  37. data/lib/a2a/store.rb +6 -0
  38. data/lib/a2a/task_store.rb +315 -0
  39. data/lib/a2a/version.rb +5 -0
  40. data/lib/a2a.rb +26 -0
  41. metadata +216 -0
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/queue"
5
+
6
+ module A2A
7
+ module Store
8
+ # Fiber-safe pub/sub for task update streaming.
9
+ #
10
+ # Following the gospel (async-job / protocol-http):
11
+ # - Async::Queue is fiber-safe (no locks needed)
12
+ # - enqueue/dequeue yield the fiber cooperatively
13
+ # - nil sentinel signals end of stream
14
+ #
15
+ # Each subscriber gets an Async::Queue. State mutations push events
16
+ # to all subscribers for the affected task. Terminal states close
17
+ # all subscribers for that task.
18
+ #
19
+ # Usage:
20
+ #
21
+ # pub_sub = A2A::Store::PubSub.new
22
+ #
23
+ # # Subscribe (returns an Async::Queue)
24
+ # queue = pub_sub.subscribe("task-123")
25
+ #
26
+ # # In another fiber, consume events:
27
+ # Async do
28
+ # while event = queue.dequeue
29
+ # process(event)
30
+ # end
31
+ # end
32
+ #
33
+ # # Publish an event to all subscribers:
34
+ # pub_sub.notify("task-123", { type: :status, data: { ... } })
35
+ #
36
+ # # Close all subscribers for a task (terminal state):
37
+ # pub_sub.close("task-123")
38
+ #
39
+ class PubSub
40
+ def initialize
41
+ @subscribers = Hash.new { |h, k| h[k] = [] }
42
+ end
43
+
44
+ # Subscribe to updates for a task.
45
+ # Returns an Async::Queue that will receive events.
46
+ # A nil sentinel signals end of stream.
47
+ def subscribe(task_id)
48
+ queue = Async::Queue.new
49
+ @subscribers[task_id] << queue
50
+ queue
51
+ end
52
+
53
+ # Remove a specific subscriber queue.
54
+ def unsubscribe(task_id, queue)
55
+ @subscribers[task_id].delete(queue)
56
+ @subscribers.delete(task_id) if @subscribers[task_id].empty?
57
+ end
58
+
59
+ # Push an event to all subscribers for a task.
60
+ #
61
+ # @param task_id [String]
62
+ # @param event [Hash] e.g. { type: :status, data: { ... } }
63
+ #
64
+ def notify(task_id, event)
65
+ @subscribers[task_id].each do |queue|
66
+ queue.enqueue(event)
67
+ end
68
+ end
69
+
70
+ # Close all subscribers for a task.
71
+ # Sends nil sentinel and removes all subscriptions.
72
+ def close(task_id)
73
+ @subscribers[task_id].each do |queue|
74
+ queue.enqueue(nil) # sentinel: end of stream
75
+ end
76
+ @subscribers.delete(task_id)
77
+ end
78
+
79
+ # Number of active subscribers for a task.
80
+ def subscriber_count(task_id)
81
+ @subscribers[task_id].size
82
+ end
83
+
84
+ # Total number of tasks with active subscribers.
85
+ def task_count
86
+ @subscribers.size
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ test do
93
+ require "async"
94
+
95
+ describe "A2A::Store::PubSub" do
96
+ it "subscribe returns an Async::Queue" do
97
+ ps = A2A::Store::PubSub.new
98
+ q = ps.subscribe("t1")
99
+ q.is_a?(Async::Queue).should == true
100
+ end
101
+
102
+ it "notify pushes events to all subscribers" do
103
+ ps = A2A::Store::PubSub.new
104
+ q1 = ps.subscribe("t1")
105
+ q2 = ps.subscribe("t1")
106
+
107
+ ps.notify("t1", { type: :status, data: "hello" })
108
+
109
+ Sync do
110
+ q1.dequeue.should == { type: :status, data: "hello" }
111
+ q2.dequeue.should == { type: :status, data: "hello" }
112
+ end
113
+ end
114
+
115
+ it "close sends nil sentinel and removes subscriptions" do
116
+ ps = A2A::Store::PubSub.new
117
+ q = ps.subscribe("t1")
118
+
119
+ ps.close("t1")
120
+
121
+ Sync do
122
+ q.dequeue.should.be.nil
123
+ end
124
+ ps.subscriber_count("t1").should == 0
125
+ end
126
+
127
+ it "unsubscribe removes a single subscriber" do
128
+ ps = A2A::Store::PubSub.new
129
+ q1 = ps.subscribe("t1")
130
+ q2 = ps.subscribe("t1")
131
+
132
+ ps.unsubscribe("t1", q1)
133
+ ps.subscriber_count("t1").should == 1
134
+
135
+ ps.notify("t1", { data: "only q2" })
136
+
137
+ Sync do
138
+ q2.dequeue[:data].should == "only q2"
139
+ end
140
+ end
141
+
142
+ it "does not leak when all subscribers unsubscribed" do
143
+ ps = A2A::Store::PubSub.new
144
+ q = ps.subscribe("t1")
145
+ ps.unsubscribe("t1", q)
146
+ ps.task_count.should == 0
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,533 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+ require "json"
5
+ require "securerandom"
6
+ require "console"
7
+
8
+ require_relative "pub_sub"
9
+ require_relative "webhooks"
10
+
11
+ module A2A
12
+ module Store
13
+ # SQLite-backed task store with async pub/sub and webhook delivery.
14
+ #
15
+ # This is the enlightened replacement for the in-memory TaskStore.
16
+ # Follows the gospel:
17
+ # - async-job's schema patterns (indexed by state, updated_at DESC)
18
+ # - async-job's duck-typed delegate protocol (#call, #start, #stop)
19
+ # - Async::Queue-based pub/sub (fiber-safe, no threads)
20
+ # - Async::HTTP::Internet for webhook delivery (no Net::HTTP)
21
+ #
22
+ # The store composes three concerns:
23
+ # 1. SQLite — persistent CRUD for tasks, push configs
24
+ # 2. PubSub — in-process streaming subscriptions
25
+ # 3. Webhooks — push notification delivery
26
+ #
27
+ # SQLite is safe for fiber-based concurrency within a single process
28
+ # because Ruby fibers are cooperatively scheduled — only one fiber
29
+ # runs at a time, so no concurrent writes can collide.
30
+ #
31
+ class SQLite
32
+ TERMINAL_STATES = %w[
33
+ TASK_STATE_COMPLETED TASK_STATE_FAILED
34
+ TASK_STATE_CANCELED TASK_STATE_REJECTED
35
+ ].freeze
36
+
37
+ attr_reader :pub_sub, :webhooks
38
+
39
+ # @param path [String] path to SQLite database file (":memory:" for in-memory)
40
+ #
41
+ def initialize(path: ":memory:")
42
+ @db = ::SQLite3::Database.new(path)
43
+ @db.results_as_hash = true
44
+ @db.execute("PRAGMA journal_mode = WAL")
45
+ @db.execute("PRAGMA synchronous = NORMAL")
46
+ @db.execute("PRAGMA foreign_keys = ON")
47
+
48
+ @pub_sub = PubSub.new
49
+ @webhooks = Webhooks.new
50
+
51
+ create_tables
52
+ end
53
+
54
+ # ── Task CRUD ──────────────────────────────────────────────────────
55
+
56
+ def create(id, context_id, push_config = nil)
57
+ now = now_ts
58
+
59
+ @db.execute(<<~SQL, [id, context_id, "TASK_STATE_SUBMITTED", "[]", "[]", now, now])
60
+ INSERT INTO tasks (id, context_id, state, artifacts, history, created_at, updated_at)
61
+ VALUES (?, ?, ?, ?, ?, ?, ?)
62
+ SQL
63
+
64
+ if push_config
65
+ cfg_id = push_config["id"] || SecureRandom.uuid
66
+ push_config = push_config.merge("id" => cfg_id, "taskId" => id)
67
+ insert_push_config(id, push_config)
68
+ end
69
+
70
+ get(id)
71
+ end
72
+
73
+ def get(id)
74
+ row = @db.get_first_row("SELECT * FROM tasks WHERE id = ?", [id])
75
+ return nil unless row
76
+ row_to_task(row)
77
+ end
78
+
79
+ def update_state(id, state, message: nil)
80
+ now = now_ts
81
+ @db.execute("UPDATE tasks SET state = ?, updated_at = ? WHERE id = ?", [state, now, id])
82
+
83
+ task = get(id)
84
+ return nil unless task
85
+
86
+ event = build_status_event(task, message)
87
+ @pub_sub.notify(id, { type: :status, data: event })
88
+ @webhooks.deliver(list_push_configs(id), { "statusUpdate" => event })
89
+
90
+ if TERMINAL_STATES.include?(state)
91
+ @pub_sub.close(id)
92
+ end
93
+
94
+ task
95
+ end
96
+
97
+ def complete(id, result)
98
+ now = now_ts
99
+ result_json = result ? JSON.generate(result) : nil
100
+ @db.execute(
101
+ "UPDATE tasks SET state = ?, result = ?, updated_at = ? WHERE id = ?",
102
+ ["TASK_STATE_COMPLETED", result_json, now, id]
103
+ )
104
+
105
+ task = get(id)
106
+ return nil unless task
107
+
108
+ event = build_status_event(task)
109
+ @pub_sub.notify(id, { type: :status, data: event })
110
+ @webhooks.deliver(list_push_configs(id), { "statusUpdate" => event })
111
+ @pub_sub.close(id)
112
+
113
+ task
114
+ end
115
+
116
+ def fail(id, msg)
117
+ now = now_ts
118
+ result_json = msg ? JSON.generate(msg) : nil
119
+ @db.execute(
120
+ "UPDATE tasks SET state = ?, result = ?, updated_at = ? WHERE id = ?",
121
+ ["TASK_STATE_FAILED", result_json, now, id]
122
+ )
123
+
124
+ task = get(id)
125
+ return nil unless task
126
+
127
+ event = build_status_event(task)
128
+ @pub_sub.notify(id, { type: :status, data: event })
129
+ @webhooks.deliver(list_push_configs(id), { "statusUpdate" => event })
130
+ @pub_sub.close(id)
131
+
132
+ task
133
+ end
134
+
135
+ def cancel(id)
136
+ update_state(id, "TASK_STATE_CANCELED")
137
+ end
138
+
139
+ def terminal?(id)
140
+ task = get(id)
141
+ task && TERMINAL_STATES.include?(task[:state])
142
+ end
143
+
144
+ # ── Artifacts ──────────────────────────────────────────────────────
145
+
146
+ def add_artifact(id, artifact)
147
+ task = get(id)
148
+ return nil unless task
149
+
150
+ artifacts = task[:artifacts]
151
+ artifacts << artifact
152
+ now = now_ts
153
+
154
+ @db.execute(
155
+ "UPDATE tasks SET artifacts = ?, updated_at = ? WHERE id = ?",
156
+ [JSON.generate(artifacts), now, id]
157
+ )
158
+
159
+ event = {
160
+ "taskId" => id,
161
+ "contextId" => task[:context_id],
162
+ "artifact" => artifact,
163
+ "append" => false,
164
+ "lastChunk" => true,
165
+ }
166
+ @pub_sub.notify(id, { type: :artifact, data: event })
167
+ @webhooks.deliver(list_push_configs(id), { "artifactUpdate" => event })
168
+
169
+ get(id)
170
+ end
171
+
172
+ # ── History ────────────────────────────────────────────────────────
173
+
174
+ def add_message(id, msg)
175
+ task = get(id)
176
+ return nil unless task
177
+
178
+ history = task[:history]
179
+ history << msg
180
+
181
+ @db.execute(
182
+ "UPDATE tasks SET history = ?, updated_at = ? WHERE id = ?",
183
+ [JSON.generate(history), now_ts, id]
184
+ )
185
+
186
+ get(id)
187
+ end
188
+
189
+ # ── Listing ────────────────────────────────────────────────────────
190
+
191
+ def list(context_id: nil, state: nil)
192
+ conditions = []
193
+ params = []
194
+
195
+ if context_id
196
+ conditions << "context_id = ?"
197
+ params << context_id
198
+ end
199
+
200
+ if state
201
+ conditions << "state = ?"
202
+ params << state
203
+ end
204
+
205
+ where = conditions.empty? ? "" : "WHERE #{conditions.join(" AND ")}"
206
+ sql = "SELECT * FROM tasks #{where} ORDER BY updated_at DESC"
207
+
208
+ @db.execute(sql, params).map { |row| row_to_task(row) }
209
+ end
210
+
211
+ # ── Push Notification Config CRUD ──────────────────────────────────
212
+
213
+ def create_push_config(task_id, config)
214
+ task = get(task_id)
215
+ return nil unless task
216
+
217
+ cfg_id = config["id"] || SecureRandom.uuid
218
+ config = config.merge("id" => cfg_id, "taskId" => task_id)
219
+ insert_push_config(task_id, config)
220
+ config
221
+ end
222
+
223
+ def get_push_config(task_id, config_id)
224
+ row = @db.get_first_row(
225
+ "SELECT * FROM push_configs WHERE task_id = ? AND id = ?",
226
+ [task_id, config_id]
227
+ )
228
+ return nil unless row
229
+ row_to_push_config(row)
230
+ end
231
+
232
+ def list_push_configs(task_id)
233
+ @db.execute(
234
+ "SELECT * FROM push_configs WHERE task_id = ? ORDER BY created_at",
235
+ [task_id]
236
+ ).map { |row| row_to_push_config(row) }
237
+ end
238
+
239
+ def delete_push_config(task_id, config_id)
240
+ @db.execute(
241
+ "DELETE FROM push_configs WHERE task_id = ? AND id = ?",
242
+ [task_id, config_id]
243
+ )
244
+ end
245
+
246
+ # ── Streaming / Pub-Sub ────────────────────────────────────────────
247
+
248
+ def subscribe(task_id)
249
+ task = get(task_id)
250
+ return nil unless task
251
+ @pub_sub.subscribe(task_id)
252
+ end
253
+
254
+ def unsubscribe(task_id, queue)
255
+ @pub_sub.unsubscribe(task_id, queue)
256
+ end
257
+
258
+ # ── Lifecycle (async-job duck-type) ────────────────────────────────
259
+
260
+ def start
261
+ # No-op for now. Could start background cleanup tasks.
262
+ end
263
+
264
+ def stop
265
+ @db.close if @db
266
+ end
267
+
268
+ private
269
+
270
+ def create_tables
271
+ @db.execute_batch(<<~SQL)
272
+ CREATE TABLE IF NOT EXISTS tasks (
273
+ id TEXT PRIMARY KEY,
274
+ context_id TEXT NOT NULL,
275
+ state TEXT NOT NULL DEFAULT 'TASK_STATE_SUBMITTED',
276
+ result TEXT,
277
+ artifacts TEXT NOT NULL DEFAULT '[]',
278
+ history TEXT NOT NULL DEFAULT '[]',
279
+ created_at TEXT NOT NULL,
280
+ updated_at TEXT NOT NULL
281
+ );
282
+
283
+ CREATE INDEX IF NOT EXISTS idx_tasks_context
284
+ ON tasks(context_id);
285
+ CREATE INDEX IF NOT EXISTS idx_tasks_state
286
+ ON tasks(state);
287
+ CREATE INDEX IF NOT EXISTS idx_tasks_updated
288
+ ON tasks(updated_at DESC);
289
+
290
+ CREATE TABLE IF NOT EXISTS push_configs (
291
+ id TEXT PRIMARY KEY,
292
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
293
+ url TEXT NOT NULL,
294
+ token TEXT,
295
+ auth_scheme TEXT,
296
+ auth_credentials TEXT,
297
+ created_at TEXT NOT NULL
298
+ );
299
+
300
+ CREATE INDEX IF NOT EXISTS idx_push_configs_task
301
+ ON push_configs(task_id);
302
+ SQL
303
+ end
304
+
305
+ def now_ts
306
+ Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
307
+ end
308
+
309
+ def row_to_task(row)
310
+ {
311
+ id: row["id"],
312
+ context_id: row["context_id"],
313
+ state: row["state"],
314
+ result: row["result"] ? JSON.parse(row["result"]) : nil,
315
+ artifacts: JSON.parse(row["artifacts"] || "[]"),
316
+ history: JSON.parse(row["history"] || "[]"),
317
+ created_at: row["created_at"],
318
+ updated_at: row["updated_at"],
319
+ }
320
+ end
321
+
322
+ def row_to_push_config(row)
323
+ config = {
324
+ "id" => row["id"],
325
+ "taskId" => row["task_id"],
326
+ "url" => row["url"],
327
+ }
328
+ config["token"] = row["token"] if row["token"]
329
+ if row["auth_scheme"]
330
+ config["authentication"] = {
331
+ "scheme" => row["auth_scheme"],
332
+ "credentials" => row["auth_credentials"],
333
+ }
334
+ end
335
+ config
336
+ end
337
+
338
+ def insert_push_config(task_id, config)
339
+ auth = config["authentication"] || {}
340
+ params = [
341
+ config["id"], task_id, config["url"],
342
+ config["token"],
343
+ auth["scheme"], auth["credentials"],
344
+ now_ts
345
+ ]
346
+ @db.execute(<<~SQL, params)
347
+ INSERT INTO push_configs (id, task_id, url, token, auth_scheme, auth_credentials, created_at)
348
+ VALUES (?, ?, ?, ?, ?, ?, ?)
349
+ SQL
350
+ end
351
+
352
+ def build_status_event(task, message = nil)
353
+ event = {
354
+ "taskId" => task[:id],
355
+ "contextId" => task[:context_id],
356
+ "status" => {
357
+ "state" => task[:state],
358
+ "timestamp" => task[:updated_at],
359
+ },
360
+ }
361
+ event["status"]["message"] = message if message
362
+ event
363
+ end
364
+ end
365
+ end
366
+ end
367
+
368
+ test do
369
+ describe "A2A::Store::SQLite" do
370
+ it "creates and retrieves tasks" do
371
+ store = A2A::Store::SQLite.new
372
+ task = store.create("t1", "ctx1")
373
+
374
+ task[:id].should == "t1"
375
+ task[:context_id].should == "ctx1"
376
+ task[:state].should == "TASK_STATE_SUBMITTED"
377
+ task[:artifacts].should == []
378
+ task[:history].should == []
379
+
380
+ fetched = store.get("t1")
381
+ fetched[:id].should == "t1"
382
+ end
383
+
384
+ it "returns nil for missing tasks" do
385
+ store = A2A::Store::SQLite.new
386
+ store.get("nonexistent").should.be.nil
387
+ end
388
+
389
+ it "updates task state" do
390
+ store = A2A::Store::SQLite.new
391
+ store.create("t1", "ctx1")
392
+
393
+ store.update_state("t1", "TASK_STATE_WORKING")
394
+ task = store.get("t1")
395
+ task[:state].should == "TASK_STATE_WORKING"
396
+ end
397
+
398
+ it "completes tasks" do
399
+ store = A2A::Store::SQLite.new
400
+ store.create("t1", "ctx1")
401
+
402
+ store.complete("t1", { "answer" => 42 })
403
+ task = store.get("t1")
404
+ task[:state].should == "TASK_STATE_COMPLETED"
405
+ task[:result].should == { "answer" => 42 }
406
+ end
407
+
408
+ it "fails tasks" do
409
+ store = A2A::Store::SQLite.new
410
+ store.create("t1", "ctx1")
411
+
412
+ store.fail("t1", "something broke")
413
+ task = store.get("t1")
414
+ task[:state].should == "TASK_STATE_FAILED"
415
+ end
416
+
417
+ it "cancels tasks" do
418
+ store = A2A::Store::SQLite.new
419
+ store.create("t1", "ctx1")
420
+
421
+ store.cancel("t1")
422
+ task = store.get("t1")
423
+ task[:state].should == "TASK_STATE_CANCELED"
424
+ end
425
+
426
+ it "detects terminal states" do
427
+ store = A2A::Store::SQLite.new
428
+ store.create("t1", "ctx1")
429
+
430
+ store.terminal?("t1").should == false
431
+ store.complete("t1", nil)
432
+ store.terminal?("t1").should == true
433
+ end
434
+
435
+ it "adds artifacts" do
436
+ store = A2A::Store::SQLite.new
437
+ store.create("t1", "ctx1")
438
+
439
+ artifact = { "artifactId" => "a1", "parts" => [{ "text" => "hello" }] }
440
+ store.add_artifact("t1", artifact)
441
+
442
+ task = store.get("t1")
443
+ task[:artifacts].length.should == 1
444
+ task[:artifacts].first["artifactId"].should == "a1"
445
+ end
446
+
447
+ it "adds messages to history" do
448
+ store = A2A::Store::SQLite.new
449
+ store.create("t1", "ctx1")
450
+
451
+ msg = { "messageId" => "m1", "role" => "ROLE_USER", "parts" => [{ "text" => "hi" }] }
452
+ store.add_message("t1", msg)
453
+
454
+ task = store.get("t1")
455
+ task[:history].length.should == 1
456
+ task[:history].first["messageId"].should == "m1"
457
+ end
458
+
459
+ it "lists tasks with filtering" do
460
+ store = A2A::Store::SQLite.new
461
+ store.create("t1", "ctx1")
462
+ store.create("t2", "ctx1")
463
+ store.create("t3", "ctx2")
464
+ store.complete("t2", nil)
465
+
466
+ # All tasks
467
+ store.list.length.should == 3
468
+
469
+ # By context
470
+ store.list(context_id: "ctx1").length.should == 2
471
+
472
+ # By state
473
+ store.list(state: "TASK_STATE_COMPLETED").length.should == 1
474
+ store.list(state: "TASK_STATE_COMPLETED").first[:id].should == "t2"
475
+
476
+ # Both filters
477
+ store.list(context_id: "ctx1", state: "TASK_STATE_SUBMITTED").length.should == 1
478
+ end
479
+
480
+ it "manages push notification configs" do
481
+ store = A2A::Store::SQLite.new
482
+ store.create("t1", "ctx1")
483
+
484
+ config = store.create_push_config("t1", {
485
+ "url" => "https://example.com/hook",
486
+ "token" => "secret",
487
+ "authentication" => { "scheme" => "Bearer", "credentials" => "abc123" },
488
+ })
489
+
490
+ config["url"].should == "https://example.com/hook"
491
+ config["token"].should == "secret"
492
+ config["id"].should.not.be.nil
493
+
494
+ # Get
495
+ fetched = store.get_push_config("t1", config["id"])
496
+ fetched["url"].should == "https://example.com/hook"
497
+ fetched["authentication"]["scheme"].should == "Bearer"
498
+
499
+ # List
500
+ store.list_push_configs("t1").length.should == 1
501
+
502
+ # Delete
503
+ store.delete_push_config("t1", config["id"])
504
+ store.list_push_configs("t1").length.should == 0
505
+ end
506
+
507
+ it "creates tasks with inline push config" do
508
+ store = A2A::Store::SQLite.new
509
+ store.create("t1", "ctx1", {
510
+ "url" => "https://example.com/hook",
511
+ "token" => "tok",
512
+ })
513
+
514
+ configs = store.list_push_configs("t1")
515
+ configs.length.should == 1
516
+ configs.first["url"].should == "https://example.com/hook"
517
+ end
518
+
519
+ it "provides pub/sub subscriptions" do
520
+ store = A2A::Store::SQLite.new
521
+ store.create("t1", "ctx1")
522
+
523
+ queue = store.subscribe("t1")
524
+ queue.should.not.be.nil
525
+ queue.is_a?(Async::Queue).should == true
526
+ end
527
+
528
+ it "returns nil subscribing to nonexistent task" do
529
+ store = A2A::Store::SQLite.new
530
+ store.subscribe("nonexistent").should.be.nil
531
+ end
532
+ end
533
+ end