a2a-exposed 0.8.14 → 0.8.15

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,342 @@
1
+ // Push registration and inbox acknowledgement.
2
+ // A connector send attaches a push config only when the peer card advertises it, and stores the token
3
+ // hash before SendMessage returns. Inbox marks a reply seen only when the stored update is still the
4
+ // snapshot that response returned, on every storage backend this file runs against.
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { sha256 } from "../src/a2a.ts";
8
+ import { dispatch, runOwnerRpc } from "../src/index.ts";
9
+ import { hostedTenantContext, resolveTenant, type WorkerBindings } from "../src/tenancy.ts";
10
+ import { d1 } from "./d1.ts";
11
+
12
+ const MIGRATIONS = new URL("../migrations/", import.meta.url);
13
+ const ectx = { waitUntil(_p: Promise<unknown>) {}, passThroughOnException() {} };
14
+ const OWNER = "owner-secret";
15
+
16
+ type Row = { taskId?: string; contextId?: string; text?: string; kind?: string; state?: string | null };
17
+ type Hist = { event?: string; text?: string; state?: string | null };
18
+ type SendBody = { text?: string; isError?: boolean };
19
+
20
+ function reqUrl(input: RequestInfo | URL): string {
21
+ if (typeof input === "string") return input;
22
+ if (input instanceof URL) return input.href;
23
+ return input.url;
24
+ }
25
+
26
+ function single(url: string) {
27
+ const db = d1(MIGRATIONS);
28
+ const ctx = resolveTenant({ DB: db, PUBLIC_URL: url, OWNER_TOKEN: OWNER, AGENT_NAME: "Owner" } as WorkerBindings);
29
+ return { db, ctx };
30
+ }
31
+
32
+ async function owner(ctx: ReturnType<typeof resolveTenant>, method: string, path: string, body?: unknown) {
33
+ const res = await dispatch(new Request(ctx.PUBLIC_URL + path, {
34
+ method, headers: { authorization: `Bearer ${OWNER}`, "content-type": "application/json" },
35
+ body: body === undefined ? undefined : JSON.stringify(body),
36
+ }), ctx, ectx as never);
37
+ const text = await res.text();
38
+ let data: unknown = text;
39
+ try { data = JSON.parse(text); } catch { /* plain */ }
40
+ return { status: res.status, data, text };
41
+ }
42
+
43
+ function card(url: string, version: string, push: boolean | undefined) {
44
+ const body: Record<string, unknown> = {
45
+ name: "Peer",
46
+ url: `${url}/`,
47
+ supportedInterfaces: [{ url: `${url}/`, protocolBinding: "JSONRPC", protocolVersion: version }],
48
+ };
49
+ if (push !== undefined) body.capabilities = { pushNotifications: push };
50
+ return body;
51
+ }
52
+
53
+ function taskResult(id: string, contextId: string, version: string) {
54
+ const task = { id, contextId, status: { state: version.startsWith("1") ? "TASK_STATE_COMPLETED" : "completed" } };
55
+ return version.startsWith("1") ? { task } : task;
56
+ }
57
+
58
+ /** Run `inject` after the inbox's outbound read returns and before that read's caller continues. */
59
+ function duringOutboundRead(db: { prepare: (sql: string) => any }, inject: () => Promise<void>) {
60
+ const orig = db.prepare.bind(db);
61
+ let fired = false;
62
+ db.prepare = (sql: string) => {
63
+ const built = orig(sql);
64
+ if (fired || !sql.includes("FROM outbound WHERE updates_json")) return built;
65
+ const wrap = (s: any): any => {
66
+ const origAll = s.all.bind(s);
67
+ const origBind = s.bind.bind(s);
68
+ s.bind = (...a: unknown[]) => wrap(origBind(...a));
69
+ s.all = async () => {
70
+ const rows = await origAll();
71
+ if (fired) return rows;
72
+ fired = true;
73
+ db.prepare = orig;
74
+ await inject();
75
+ return rows;
76
+ };
77
+ return s;
78
+ };
79
+ return wrap(built);
80
+ };
81
+ }
82
+
83
+ async function seedReply(db: { prepare: (sql: string) => any }, taskId: string, contextId: string, token: string, text: string) {
84
+ const updates = JSON.stringify([{
85
+ receivedAt: "2026-01-01T00:00:00.000Z",
86
+ payload: { task: { id: taskId, contextId, status: { state: "completed", message: { parts: [{ text }] } } } },
87
+ }]);
88
+ await db.prepare(
89
+ `INSERT INTO outbound (task_id, context_id, peer, endpoint, protocol, push_token_hash, sent_at, updates_json, updates_seen)
90
+ VALUES (?, ?, 'ada', 'https://ada.example.net/', '1.0', ?, '2026-01-01T00:00:00.000Z', ?, 0)`,
91
+ ).bind(taskId, contextId, await sha256(token), updates).run();
92
+ await db.prepare("INSERT INTO wakes (context_id, last_sent_ms) VALUES (?, ?)").bind(contextId, Date.now()).run();
93
+ }
94
+
95
+ test("a push between the inbox read and the seen mark stays unseen", async () => {
96
+ const { db, ctx } = single("https://owner.example.net");
97
+ const token = "push-token-seen";
98
+ await seedReply(db, "task-seen", "ctx-seen", token, "first reply");
99
+ duringOutboundRead(db, async () => {
100
+ const push = await dispatch(new Request("https://owner.example.net/push", {
101
+ method: "POST",
102
+ headers: { "content-type": "application/json", "x-a2a-notification-token": token },
103
+ body: JSON.stringify({ task: { id: "task-seen", contextId: "ctx-seen", status: { state: "completed", message: { parts: [{ text: "newer reply" }] } } } }),
104
+ }), ctx, ectx as never);
105
+ assert.equal(push.status, 200);
106
+ });
107
+ const first = await owner(ctx, "GET", "/owner/inbox");
108
+ assert.equal(first.status, 200, first.text);
109
+ const shown = (first.data as Row[]).find((r) => r.kind === "outbound" && r.taskId === "task-seen");
110
+ assert.equal(shown?.text, "first reply");
111
+ const mid = await db.prepare("SELECT updates_seen, updates_json FROM outbound WHERE task_id = ?").bind("task-seen").first() as { updates_seen?: number; updates_json?: string };
112
+ assert.equal(Number(mid.updates_seen), 0);
113
+ assert.match(String(mid.updates_json), /newer reply/);
114
+ const wake = await db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").bind("ctx-seen").first() as { pending_json?: string } | null;
115
+ assert.match(String(wake?.pending_json), /outbound_update/);
116
+ const second = await owner(ctx, "GET", "/owner/inbox");
117
+ const again = (second.data as Row[]).find((r) => r.kind === "outbound" && r.taskId === "task-seen");
118
+ assert.equal(again?.text, "newer reply");
119
+ const done = await db.prepare("SELECT updates_seen FROM outbound WHERE task_id = ?").bind("task-seen").first() as { updates_seen?: number };
120
+ assert.equal(Number(done.updates_seen), 1);
121
+ const cleared = await db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").bind("ctx-seen").first() as { pending_json?: string | null };
122
+ assert.equal(cleared.pending_json, null);
123
+ });
124
+
125
+ test("hosted inbox acknowledgement also waits for the returned snapshot", async () => {
126
+ const db = d1(MIGRATIONS);
127
+ const hash = await sha256("hosted-owner");
128
+ const ctx = hostedTenantContext({
129
+ DB: db, TENANCY: "host", TENANT_SECRETS_KEY: "platform-key", PUBLIC_URL: "https://owner.example.net", OWNER_TOKEN: "platform-owner",
130
+ } as WorkerBindings, {
131
+ id: "ten_seen", db, publicUrl: "https://owner.example.net", ownerTokenHash: hash, status: "active", config: { AGENT_NAME: "Owner" },
132
+ });
133
+ const token = "push-token-rpc";
134
+ await seedReply(db, "task-rpc", "ctx-rpc", token, "first reply");
135
+ duringOutboundRead(db, async () => {
136
+ const push = await dispatch(new Request("https://owner.example.net/push", {
137
+ method: "POST",
138
+ headers: { "content-type": "application/json", "x-a2a-notification-token": token },
139
+ body: JSON.stringify({ task: { id: "task-rpc", contextId: "ctx-rpc", status: { state: "completed", message: { parts: [{ text: "newer reply" }] } } } }),
140
+ }), ctx, ectx as never);
141
+ assert.equal(push.status, 200);
142
+ });
143
+ const read = () => runOwnerRpc(ctx, ectx as never, { accountId: "acct_seen", method: "GET", path: "/owner/inbox" });
144
+ const first = await read();
145
+ assert.equal(first.status, 200, JSON.stringify(first.body));
146
+ const shown = (first.body as Row[]).find((r) => r.kind === "outbound" && r.taskId === "task-rpc");
147
+ assert.equal(shown?.text, "first reply");
148
+ const mid = await db.prepare("SELECT updates_seen FROM outbound WHERE task_id = ?").bind("task-rpc").first() as { updates_seen?: number };
149
+ assert.equal(Number(mid.updates_seen), 0);
150
+ const wake = await db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").bind("ctx-rpc").first() as { pending_json?: string } | null;
151
+ assert.match(String(wake?.pending_json), /outbound_update/);
152
+ const second = await read();
153
+ const again = (second.body as Row[]).find((r) => r.kind === "outbound" && r.taskId === "task-rpc");
154
+ assert.equal(again?.text, "newer reply");
155
+ const done = await db.prepare("SELECT updates_seen FROM outbound WHERE task_id = ?").bind("task-rpc").first() as { updates_seen?: number };
156
+ assert.equal(Number(done.updates_seen), 1);
157
+ });
158
+
159
+ test("send omits push unless the peer card advertises it", async (t) => {
160
+ const { db, ctx } = single("https://owner.example.net");
161
+ const seen: { host: string; configuration?: Record<string, unknown> }[] = [];
162
+ const orig = globalThis.fetch;
163
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
164
+ const url = new URL(reqUrl(input));
165
+ if (init?.method !== "POST") {
166
+ const push = url.host === "bea.example.net" ? false : undefined;
167
+ return new Response(JSON.stringify(card(`https://${url.host}`, "1.0", push)), { status: 200, headers: { "content-type": "application/json" } });
168
+ }
169
+ const body = JSON.parse(String(init.body)) as { params?: { configuration?: Record<string, unknown> } };
170
+ seen.push({ host: url.host, configuration: body.params?.configuration });
171
+ const pend = await db.prepare("SELECT task_id FROM outbound WHERE task_id LIKE 'pend.%'").all() as { results: unknown[] };
172
+ assert.equal(pend.results.length, 0);
173
+ const id = url.host === "bea.example.net" ? "task-false" : "task-missing";
174
+ return new Response(JSON.stringify({ jsonrpc: "2.0", id: "1", result: taskResult(id, "ctx-nopush", "1.0") }), { status: 200, headers: { "content-type": "application/json" } });
175
+ }) as typeof fetch;
176
+ t.after(() => { globalThis.fetch = orig; });
177
+
178
+ for (const [alias, url] of [["ada", "https://ada.example.net"], ["bea", "https://bea.example.net"]] as const) {
179
+ const synced = await owner(ctx, "PUT", `/owner/outbound-peers/${alias}`, { url });
180
+ assert.equal(synced.status, 200, synced.text);
181
+ }
182
+ for (const [alias, taskId] of [["ada", "task-missing"], ["bea", "task-false"]] as const) {
183
+ const sent = await owner(ctx, "POST", "/owner/mcp/send", { to: alias, text: "hello", contextId: "ctx-nopush" });
184
+ assert.equal(sent.status, 200, sent.text);
185
+ assert.equal((sent.data as SendBody).isError, false, sent.text);
186
+ assert.match((sent.data as SendBody).text || "", new RegExp(`task ${taskId}`));
187
+ const row = await db.prepare("SELECT push_token_hash FROM outbound WHERE task_id = ?").bind(taskId).first() as { push_token_hash?: string | null };
188
+ assert.equal(row.push_token_hash, null);
189
+ }
190
+ assert.equal(seen.length, 2);
191
+ for (const call of seen) {
192
+ assert.equal(call.configuration?.returnImmediately, true);
193
+ assert.equal("taskPushNotificationConfig" in (call.configuration || {}), false);
194
+ assert.equal("pushNotificationConfig" in (call.configuration || {}), false);
195
+ }
196
+ });
197
+
198
+ test("the push hash is stored before SendMessage returns, and a fast callback authenticates", async (t) => {
199
+ const { db, ctx } = single("https://owner.example.net");
200
+ const outputs: string[] = [];
201
+ let token = "";
202
+ const orig = globalThis.fetch;
203
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
204
+ const url = new URL(reqUrl(input));
205
+ if (init?.method !== "POST") {
206
+ const version = url.host === "cara.example.net" ? "0.3" : "1.0";
207
+ return new Response(JSON.stringify(card(`https://${url.host}`, version, true)), { status: 200, headers: { "content-type": "application/json" } });
208
+ }
209
+ const body = JSON.parse(String(init.body)) as { params?: { configuration?: { taskPushNotificationConfig?: { token?: string }; pushNotificationConfig?: { token?: string } } } };
210
+ const cfg = body.params?.configuration || {};
211
+ const push = cfg.taskPushNotificationConfig || cfg.pushNotificationConfig;
212
+ assert.equal(typeof push?.token, "string");
213
+ token = push!.token!;
214
+ if (url.host === "cara.example.net") {
215
+ assert.equal("taskPushNotificationConfig" in cfg, false);
216
+ assert.equal(typeof cfg.pushNotificationConfig?.token, "string");
217
+ } else {
218
+ assert.equal(typeof cfg.taskPushNotificationConfig?.token, "string");
219
+ }
220
+ const pend = await db.prepare("SELECT push_token_hash FROM outbound WHERE task_id LIKE 'pend.%'").all() as { results: { push_token_hash?: string }[] };
221
+ assert.equal(pend.results.length, 1);
222
+ assert.equal(pend.results[0]?.push_token_hash, await sha256(token));
223
+ const taskId = url.host === "cara.example.net" ? "task-03" : "task-race";
224
+ const contextId = url.host === "cara.example.net" ? "ctx-03" : "ctx-race";
225
+ if (url.host !== "cara.example.net") {
226
+ const pushRes = await dispatch(new Request("https://owner.example.net/push", {
227
+ method: "POST",
228
+ headers: { "content-type": "application/json", "x-a2a-notification-token": token },
229
+ body: JSON.stringify({ task: { id: taskId, contextId, status: { state: "completed", message: { parts: [{ text: "raced reply" }] } } } }),
230
+ }), ctx, ectx as never);
231
+ const pushText = await pushRes.text();
232
+ outputs.push(pushText);
233
+ assert.equal(pushRes.status, 200, pushText);
234
+ }
235
+ const version = url.host === "cara.example.net" ? "0.3" : "1.0";
236
+ return new Response(JSON.stringify({ jsonrpc: "2.0", id: "1", result: taskResult(taskId, contextId, version) }), { status: 200, headers: { "content-type": "application/json" } });
237
+ }) as typeof fetch;
238
+ t.after(() => { globalThis.fetch = orig; });
239
+
240
+ for (const [alias, url] of [["ada", "https://ada.example.net"], ["cara", "https://cara.example.net"]] as const) {
241
+ const synced = await owner(ctx, "PUT", `/owner/outbound-peers/${alias}`, { url });
242
+ assert.equal(synced.status, 200, synced.text);
243
+ }
244
+ const raced = await owner(ctx, "POST", "/owner/mcp/send", { to: "ada", text: "hello", contextId: "ctx-race" });
245
+ outputs.push(raced.text);
246
+ assert.equal((raced.data as SendBody).isError, false, raced.text);
247
+ const racedToken = token;
248
+ const racedRow = await db.prepare("SELECT task_id, push_token_hash, updates_json FROM outbound WHERE task_id = ?").bind("task-race").first() as { push_token_hash?: string; updates_json?: string };
249
+ assert.equal(racedRow.push_token_hash, await sha256(racedToken));
250
+ assert.match(String(racedRow.updates_json), /raced reply/);
251
+ const pend = await db.prepare("SELECT task_id FROM outbound WHERE task_id LIKE 'pend.%'").all() as { results: unknown[] };
252
+ assert.equal(pend.results.length, 0);
253
+ const history = await owner(ctx, "GET", "/owner/history/ctx-race");
254
+ assert.equal((history.data as Hist[]).some((r) => r.event === "push" && r.text === "raced reply"), true, history.text);
255
+ const inbox = await owner(ctx, "GET", "/owner/inbox");
256
+ outputs.push(inbox.text);
257
+ const reply = (inbox.data as Row[]).find((r) => r.kind === "outbound" && r.taskId === "task-race");
258
+ assert.equal(reply?.text, "raced reply");
259
+ assert.equal(outputs.join("\n").includes(racedToken), false);
260
+
261
+ const older = await owner(ctx, "POST", "/owner/mcp/send", { to: "cara", text: "hello", contextId: "ctx-03" });
262
+ outputs.push(older.text);
263
+ assert.equal((older.data as SendBody).isError, false, older.text);
264
+ const oldRow = await db.prepare("SELECT push_token_hash FROM outbound WHERE task_id = ?").bind("task-03").first() as { push_token_hash?: string };
265
+ assert.equal(oldRow.push_token_hash, await sha256(token));
266
+ assert.equal(outputs.join("\n").includes(token), false);
267
+ });
268
+
269
+ test("a failed send drops an empty pending push row and keeps a callback that already arrived", async (t) => {
270
+ const { db, ctx } = single("https://owner.example.net");
271
+ let mode: "throw" | "push-then-throw" = "throw";
272
+ const orig = globalThis.fetch;
273
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
274
+ const url = new URL(reqUrl(input));
275
+ if (init?.method !== "POST") return new Response(JSON.stringify(card(`https://${url.host}`, "1.0", true)), { status: 200, headers: { "content-type": "application/json" } });
276
+ const body = JSON.parse(String(init.body)) as { params?: { configuration?: { taskPushNotificationConfig?: { token?: string } } } };
277
+ const token = body.params?.configuration?.taskPushNotificationConfig?.token || "";
278
+ const pend = await db.prepare("SELECT push_token_hash FROM outbound WHERE task_id LIKE 'pend.%'").all() as { results: { push_token_hash?: string }[] };
279
+ assert.equal(pend.results.length, 1);
280
+ assert.equal(pend.results[0]?.push_token_hash, await sha256(token));
281
+ if (mode === "push-then-throw") {
282
+ const push = await dispatch(new Request("https://owner.example.net/push", {
283
+ method: "POST",
284
+ headers: { "content-type": "application/json", "x-a2a-notification-token": token },
285
+ body: JSON.stringify({ message: { taskId: "task-kept", contextId: "ctx-kept", role: "ROLE_AGENT", parts: [{ text: "kept reply" }] } }),
286
+ }), ctx, ectx as never);
287
+ assert.equal(push.status, 200);
288
+ }
289
+ throw new Error("peer down");
290
+ }) as typeof fetch;
291
+ t.after(() => { globalThis.fetch = orig; });
292
+ const synced = await owner(ctx, "PUT", "/owner/outbound-peers/ada", { url: "https://ada.example.net" });
293
+ assert.equal(synced.status, 200, synced.text);
294
+
295
+ const failed = await owner(ctx, "POST", "/owner/mcp/send", { to: "ada", text: "hello", contextId: "ctx-fail" });
296
+ assert.equal((failed.data as SendBody).isError, true, failed.text);
297
+ const gone = await db.prepare("SELECT task_id FROM outbound").all() as { results: unknown[] };
298
+ assert.equal(gone.results.length, 0);
299
+
300
+ mode = "push-then-throw";
301
+ const raced = await owner(ctx, "POST", "/owner/mcp/send", { to: "ada", text: "hello", contextId: "ctx-kept" });
302
+ assert.equal((raced.data as SendBody).isError, true, raced.text);
303
+ const kept = await db.prepare("SELECT task_id, updates_json FROM outbound").all() as { results: { task_id?: string; updates_json?: string }[] };
304
+ assert.equal(kept.results.length, 1);
305
+ assert.equal(kept.results[0]?.task_id, "task-kept");
306
+ assert.match(String(kept.results[0]?.updates_json), /kept reply/);
307
+ const history = await owner(ctx, "GET", "/owner/history/ctx-kept");
308
+ assert.equal((history.data as Hist[]).some((r) => r.event === "push" && r.text === "kept reply"), true, history.text);
309
+ });
310
+
311
+ test("message and artifactUpdate pushes supply inbox text", async () => {
312
+ const { db, ctx } = single("https://owner.example.net");
313
+ const cases = [
314
+ { id: "t-msg", context: "ctx-msg", token: "tok-msg", text: "from message", state: null as string | null, body: { message: { taskId: "t-msg", contextId: "ctx-msg", role: "ROLE_AGENT", parts: [{ text: "from message" }] } } },
315
+ { id: "t-art", context: "ctx-art", token: "tok-art", text: "from artifact", state: null, body: { artifactUpdate: { taskId: "t-art", contextId: "ctx-art", artifact: { artifactId: "a1", parts: [{ text: "from artifact" }] } } } },
316
+ { id: "t-raw", context: "ctx-raw", token: "tok-raw", text: "raw artifact", state: null, body: { kind: "artifact-update", taskId: "t-raw", contextId: "ctx-raw", artifact: { artifactId: "a2", parts: [{ kind: "text", text: "raw artifact" }] } } },
317
+ { id: "t-st", context: "ctx-st", token: "tok-st", text: "from status", state: "completed", body: { statusUpdate: { taskId: "t-st", contextId: "ctx-st", status: { state: "TASK_STATE_COMPLETED", message: { parts: [{ text: "from status" }] } } } } },
318
+ ];
319
+ for (const c of cases) {
320
+ await db.prepare(
321
+ `INSERT INTO outbound (task_id, context_id, peer, endpoint, protocol, push_token_hash, sent_at, updates_json, updates_seen)
322
+ VALUES (?, ?, 'ada', 'https://ada.example.net/', '1.0', ?, ?, '[]', 0)`,
323
+ ).bind(c.id, c.context, await sha256(c.token), `2026-01-01T00:00:0${cases.indexOf(c)}.000Z`).run();
324
+ const push = await dispatch(new Request("https://owner.example.net/push", {
325
+ method: "POST",
326
+ headers: { "content-type": "application/json", "x-a2a-notification-token": c.token },
327
+ body: JSON.stringify(c.body),
328
+ }), ctx, ectx as never);
329
+ assert.equal(push.status, 200, await push.clone().text());
330
+ }
331
+ const inbox = await owner(ctx, "GET", "/owner/inbox");
332
+ assert.equal(inbox.status, 200, inbox.text);
333
+ for (const c of cases) {
334
+ const row = (inbox.data as Row[]).find((r) => r.taskId === c.id);
335
+ assert.ok(row, inbox.text);
336
+ assert.equal(row.kind, "outbound");
337
+ assert.equal(row.text, c.text);
338
+ assert.equal(row.state, c.state);
339
+ const history = await owner(ctx, "GET", `/owner/history/${c.context}`);
340
+ assert.equal((history.data as Hist[]).some((r) => r.event === "push" && r.text === c.text && r.state === c.state), true, history.text);
341
+ }
342
+ });