a2a-exposed 0.8.21 → 0.8.23

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.
@@ -59,6 +59,10 @@ test("fresh database: every migration applies in order; a second run applies not
59
59
  assert.deepEqual(applyMigrations(db), [...FILES].sort(byNumber));
60
60
  for (const t of ["peers", "tasks", "wakes", "wake_budget", "device_requests", "pairing_rate", "settings", "facade_owners", "oauth_clients", "oauth_codes", "mcp_grants", "outbound_peers", "oauth_client_metadata", "oidc_txns", "conversation_owners", "reply_deliveries"]) assert.ok(tables(db).includes(t), t);
61
61
  assert.ok(columns(db, "peers").includes("source"));
62
+ assert.ok(columns(db, "peers").includes("identity_key"));
63
+ assert.ok(columns(db, "peers").includes("card_url"));
64
+ assert.ok(columns(db, "peers").includes("token_enc"));
65
+ assert.ok(columns(db, "device_requests").includes("identity_key"));
62
66
  assert.ok(columns(db, "device_requests").includes("replaces_label"));
63
67
  assert.ok(columns(db, "device_requests").includes("replaces_hash"));
64
68
  assert.deepEqual(applyMigrations(db), []);
@@ -67,15 +71,15 @@ test("fresh database: every migration applies in order; a second run applies not
67
71
  test("database from the 0002 fix (0001-0003 recorded): only 0004 and later apply; pending requests keep working", () => {
68
72
  const db = existingDb([sql("0001_init.sql"), sql("0002_wake_budget.sql"), sql("0003_device_pairing.sql")], ["0001_init.sql", "0002_wake_budget.sql", "0003_device_pairing.sql"]);
69
73
  db.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s) VALUES ('h', 'WDJB4827', 'pending', 1, 2, 5)").run();
70
- assert.deepEqual(applyMigrations(db), ["0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql"]);
74
+ assert.deepEqual(applyMigrations(db), ["0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql", "0014_peer_address.sql", "0015_outbound_source.sql"]);
71
75
  assert.equal((db.prepare("SELECT replaces_label FROM device_requests WHERE device_hash = 'h'").get() as any).replaces_label, null);
72
76
  });
73
77
 
74
78
  test("database from v0.2.0 (0001 and 0003 recorded): 0002 applies as a no-op and keeps the data", () => {
75
79
  const db = existingDb([sql("0001_init.sql"), sql("0003_device_pairing.sql")], ["0001_init.sql", "0003_device_pairing.sql"]);
76
80
  db.prepare("INSERT INTO wake_budget (hour, count) VALUES (1, 7)").run();
77
- assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql"]);
78
- assert.deepEqual(recorded(db), ["0001_init.sql", "0003_device_pairing.sql", "0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql"]);
81
+ assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql", "0014_peer_address.sql", "0015_outbound_source.sql"]);
82
+ assert.deepEqual(recorded(db), ["0001_init.sql", "0003_device_pairing.sql", "0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql", "0014_peer_address.sql", "0015_outbound_source.sql"]);
79
83
  assert.equal((db.prepare("SELECT count FROM wake_budget WHERE hour = 1").get() as any).count, 7);
80
84
  });
81
85
 
@@ -107,7 +111,7 @@ test("database from an earlier build without wake_budget (0001 and 0003 recorded
107
111
  assert.equal(before.data.error?.code, -32603, JSON.stringify(before.data));
108
112
  assert.equal(wakes.length, 0);
109
113
 
110
- assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql"]);
114
+ assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql", "0014_peer_address.sql", "0015_outbound_source.sql"]);
111
115
  assert.deepEqual(columns(db, "wake_budget"), ["hour", "count"]);
112
116
  assert.deepEqual(applyMigrations(db), []);
113
117
 
@@ -169,3 +173,16 @@ test("0012 lets each peer own the same context id and keeps the first row", () =
169
173
  const peers = db.prepare("SELECT peer FROM conversation_owners WHERE context_id = 'ctx-y' ORDER BY peer").all() as { peer: string }[];
170
174
  assert.deepEqual(peers.map((r) => r.peer), ["ada", "bob"]);
171
175
  });
176
+
177
+ test("0014 adds address columns and keeps an existing peer token", () => {
178
+ const prior = [...FILES].sort(byNumber).filter((f) => f !== "0014_peer_address.sql");
179
+ const db = existingDb(prior.map(sql), prior);
180
+ db.prepare("INSERT INTO peers (label, token_hash, created_at, source, user_code, client_name) VALUES ('ada', 'hash', '2026-01-01T00:00:00.000Z', 'pairing', 'WDJB4827', 'Ada')").run();
181
+ assert.deepEqual(applyMigrations(db), ["0014_peer_address.sql"]);
182
+ const row = db.prepare("SELECT token_hash, identity_key, card_url, token_enc FROM peers WHERE label = 'ada'").get() as { token_hash: string; identity_key: string | null; card_url: string | null; token_enc: string | null };
183
+ assert.equal(row.token_hash, "hash");
184
+ assert.equal(row.identity_key, null);
185
+ assert.equal(row.card_url, null);
186
+ assert.equal(row.token_enc, null);
187
+ assert.ok(columns(db, "device_requests").includes("identity_key"));
188
+ });
@@ -2,6 +2,7 @@
2
2
  // A connector send attaches a push config only when the peer card advertises it, and stores the token
3
3
  // hash before SendMessage returns. Inbox marks a reply seen only when the stored update is still the
4
4
  // snapshot that response returned, on every storage backend this file runs against.
5
+ // Revoking the peer label clears that peer's push hashes. A later push with the old token is 401.
5
6
  import { test } from "node:test";
6
7
  import assert from "node:assert/strict";
7
8
  import { sha256 } from "../src/a2a.ts";
@@ -340,3 +341,192 @@ test("message and artifactUpdate pushes supply inbox text", async () => {
340
341
  assert.equal((history.data as Hist[]).some((r) => r.event === "push" && r.text === c.text && r.state === c.state), true, history.text);
341
342
  }
342
343
  });
344
+
345
+ type OutSnap = {
346
+ task_id?: string;
347
+ context_id?: string;
348
+ peer?: string;
349
+ task_json?: string | null;
350
+ updates_json?: string;
351
+ updates_seen?: number;
352
+ hash_cleared?: number;
353
+ };
354
+
355
+ test("revoking a peer rejects its old push token on the self-hosted and hosted paths", async (t) => {
356
+ const logs: string[] = [];
357
+ const origLog = console.log;
358
+ console.log = (...args: unknown[]) => {
359
+ logs.push(args.map((a) => String(a)).join(" "));
360
+ origLog(...args);
361
+ };
362
+ const pushByHost = new Map<string, string>();
363
+ const origFetch = globalThis.fetch;
364
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
365
+ const url = new URL(reqUrl(input));
366
+ if (init?.method !== "POST") {
367
+ return new Response(JSON.stringify(card(`https://${url.host}`, "1.0", true)), { status: 200, headers: { "content-type": "application/json" } });
368
+ }
369
+ const body = JSON.parse(String(init.body)) as { params?: { configuration?: { taskPushNotificationConfig?: { token?: string } } } };
370
+ const token = body.params?.configuration?.taskPushNotificationConfig?.token || "";
371
+ pushByHost.set(url.host, token);
372
+ const taskId = url.host.startsWith("bea.") ? "task-bea" : "task-ada";
373
+ const contextId = url.host.startsWith("bea.") ? "ctx-bea" : "ctx-ada";
374
+ return new Response(JSON.stringify({ jsonrpc: "2.0", id: "1", result: taskResult(taskId, contextId, "1.0") }), { status: 200, headers: { "content-type": "application/json" } });
375
+ }) as typeof fetch;
376
+ t.after(() => {
377
+ globalThis.fetch = origFetch;
378
+ console.log = origLog;
379
+ });
380
+
381
+ const secrets: string[] = [];
382
+ const outputs: string[] = [];
383
+ const hides = (blob: string, where: string) => {
384
+ for (const secret of secrets) assert.equal(secret.length > 0 && blob.includes(secret), false, `${where} contains a token`);
385
+ };
386
+
387
+ for (const mode of ["single", "host"] as const) {
388
+ pushByHost.clear();
389
+ const db = d1(MIGRATIONS);
390
+ const publicUrl = "https://owner.example.net";
391
+ const ctx = mode === "single"
392
+ ? resolveTenant({ DB: db, PUBLIC_URL: publicUrl, OWNER_TOKEN: OWNER, AGENT_NAME: "Owner" } as WorkerBindings)
393
+ : hostedTenantContext({
394
+ DB: db, TENANCY: "host", TENANT_SECRETS_KEY: "platform-key", PUBLIC_URL: publicUrl, OWNER_TOKEN: "platform-owner",
395
+ } as WorkerBindings, {
396
+ id: "ten_revoke_push", db, publicUrl, ownerTokenHash: await sha256(OWNER), status: "active", config: { AGENT_NAME: "Owner" },
397
+ });
398
+ const http = (method: string, path: string, body?: unknown) => owner(ctx, method, path, body);
399
+ const call = async (method: string, path: string, body?: unknown) => {
400
+ if (mode === "single") return http(method, path, body);
401
+ const res = await runOwnerRpc(ctx, ectx as never, {
402
+ accountId: "acct_owner", method, path, ...(body === undefined ? {} : { body }),
403
+ });
404
+ const text = JSON.stringify(res.body);
405
+ return { status: res.status, data: res.body, text };
406
+ };
407
+ const note = (res: { text: string }) => { outputs.push(res.text); return res; };
408
+
409
+ const issue = async (label: string) => {
410
+ const issued = await http("POST", "/owner/peers", { label });
411
+ assert.equal(issued.status, 200, issued.text);
412
+ const token = (issued.data as { token?: string }).token || "";
413
+ assert.match(token, /^a2aow_/);
414
+ secrets.push(token);
415
+ return token;
416
+ };
417
+ const beaBearer = await issue("bea");
418
+ const adaBearer = await issue("ada");
419
+ for (const [alias, bearer] of [["bea", beaBearer], ["ada", adaBearer]] as const) {
420
+ const synced = note(await call("PUT", `/owner/outbound-peers/${alias}`, { url: `https://${alias}.example.net`, token: bearer }));
421
+ assert.equal(synced.status, 200, synced.text);
422
+ assert.equal(JSON.stringify(synced.data).includes(bearer), false);
423
+ }
424
+ for (const alias of ["bea", "ada"] as const) {
425
+ const sent = note(await call("POST", "/owner/mcp/send", { to: alias, text: `hello ${alias}`, contextId: `ctx-${alias}` }));
426
+ assert.equal(sent.status, 200, sent.text);
427
+ assert.equal((sent.data as SendBody).isError, false, sent.text);
428
+ assert.match((sent.data as SendBody).text || "", new RegExp(`task task-${alias}`));
429
+ }
430
+ const beaPush = pushByHost.get("bea.example.net") || "";
431
+ const adaPush = pushByHost.get("ada.example.net") || "";
432
+ assert.equal(beaPush.length > 0 && adaPush.length > 0 && beaPush !== adaPush, true);
433
+ secrets.push(beaPush, adaPush);
434
+ const pendingToken = "pending-push-token";
435
+ secrets.push(pendingToken);
436
+ await db.prepare(
437
+ `INSERT INTO outbound (task_id, context_id, peer, endpoint, protocol, push_token_hash, sent_at, updates_json, updates_seen)
438
+ VALUES ('pend.revoked', 'ctx-bea', 'bea', 'https://bea.example.net/', '1.0', ?, '2026-01-01T00:00:00.000Z', '[]', 0)`,
439
+ ).bind(await sha256(pendingToken)).run();
440
+
441
+ const missed = note(await call("DELETE", "/owner/peers/nobody"));
442
+ assert.equal(missed.status, 404, missed.text);
443
+ const beforeRevoke = await db.prepare("SELECT push_token_hash IS NULL AS cleared FROM outbound WHERE task_id = ?").bind("task-bea").first() as { cleared?: number };
444
+ assert.equal(Number(beforeRevoke.cleared), 0);
445
+
446
+ const revoked = note(await call("DELETE", "/owner/peers/bea"));
447
+ assert.equal(revoked.status, 200, revoked.text);
448
+ assert.deepEqual(revoked.data, { revoked: true, label: "bea" });
449
+ const again = note(await call("DELETE", "/owner/peers/bea"));
450
+ assert.equal(again.status, 404, again.text);
451
+
452
+ const cleared = async (taskId: string) => Number((await db.prepare("SELECT push_token_hash IS NULL AS cleared FROM outbound WHERE task_id = ?").bind(taskId).first() as { cleared?: number }).cleared);
453
+ assert.equal(await cleared("task-bea"), 1);
454
+ assert.equal(await cleared("pend.revoked"), 1);
455
+ assert.equal(await cleared("task-ada"), 0);
456
+
457
+ const snap = async (taskId: string) => await db.prepare(
458
+ `SELECT task_id, context_id, peer, task_json, updates_json, updates_seen, push_token_hash IS NULL AS hash_cleared
459
+ FROM outbound WHERE task_id = ?`,
460
+ ).bind(taskId).first() as OutSnap;
461
+ const histCount = async (contextId: string) => Number((await db.prepare("SELECT COUNT(*) AS n FROM history WHERE context_id = ?").bind(contextId).first() as { n: number }).n);
462
+ const taskCount = async () => Number((await db.prepare("SELECT COUNT(*) AS n FROM tasks").first() as { n: number }).n);
463
+ const beaBefore = await snap("task-bea");
464
+ const beaHist = await histCount("ctx-bea");
465
+ const tasksBefore = await taskCount();
466
+ const push = async (token: string, taskId: string, text: string) => {
467
+ const res = await dispatch(new Request("https://owner.example.net/push", {
468
+ method: "POST",
469
+ headers: { "content-type": "application/json", "x-a2a-notification-token": token },
470
+ body: JSON.stringify({ task: { id: taskId, contextId: "ctx-bea", status: { state: "completed", message: { parts: [{ text }] } } } }),
471
+ }), ctx, ectx as never);
472
+ const body = await res.text();
473
+ outputs.push(body);
474
+ return { status: res.status, body };
475
+ };
476
+ const rejected = await push(beaPush, "task-bea", "revoked reply");
477
+ assert.equal(rejected.status, 401);
478
+ assert.equal(rejected.body, '{"error":"unauthorized"}');
479
+ assert.deepEqual(await snap("task-bea"), beaBefore);
480
+ assert.equal(await histCount("ctx-bea"), beaHist);
481
+ assert.equal(await taskCount(), tasksBefore);
482
+ const wake = await db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").bind("ctx-bea").first() as { pending_json?: string | null } | null;
483
+ assert.equal(wake?.pending_json ?? null, null);
484
+
485
+ const pending = await push(pendingToken, "task-claimed", "pending reply");
486
+ assert.equal(pending.status, 401);
487
+ assert.equal(pending.body, '{"error":"unauthorized"}');
488
+ assert.equal(await cleared("pend.revoked"), 1);
489
+ const claimed = await db.prepare("SELECT task_id FROM outbound WHERE task_id = ?").bind("task-claimed").first();
490
+ assert.equal(claimed, null);
491
+ assert.equal(await histCount("ctx-bea"), beaHist);
492
+ assert.deepEqual(await snap("task-bea"), beaBefore);
493
+
494
+ const standin = "restored-push-token";
495
+ secrets.push(standin);
496
+ await db.prepare("UPDATE outbound SET push_token_hash = ? WHERE task_id = ?").bind(await sha256(standin), "task-bea").run();
497
+ const restored = await push(standin, "task-bea", "restored reply");
498
+ assert.equal(restored.status, 401);
499
+ assert.equal(restored.body, '{"error":"unauthorized"}');
500
+ const afterRestore = await snap("task-bea");
501
+ assert.equal(Number(afterRestore.hash_cleared), 0);
502
+ assert.equal(afterRestore.task_json, beaBefore.task_json);
503
+ assert.equal(afterRestore.updates_json, beaBefore.updates_json);
504
+ assert.equal(afterRestore.updates_seen, beaBefore.updates_seen);
505
+ assert.equal(await histCount("ctx-bea"), beaHist);
506
+
507
+ const accepted = await dispatch(new Request("https://owner.example.net/push", {
508
+ method: "POST",
509
+ headers: { "content-type": "application/json", "x-a2a-notification-token": adaPush },
510
+ body: JSON.stringify({ task: { id: "task-ada", contextId: "ctx-ada", status: { state: "completed", message: { parts: [{ text: "ada reply" }] } } } }),
511
+ }), ctx, ectx as never);
512
+ const acceptedText = await accepted.text();
513
+ outputs.push(acceptedText);
514
+ assert.equal(accepted.status, 200, acceptedText);
515
+ const adaRow = await snap("task-ada");
516
+ assert.match(String(adaRow.updates_json), /ada reply/);
517
+ assert.equal(Number(adaRow.hash_cleared), 0);
518
+ const adaHist = note(await call("GET", "/owner/history/ctx-ada"));
519
+ assert.equal((adaHist.data as Hist[]).some((r) => r.event === "push" && r.text === "ada reply"), true, adaHist.text);
520
+ const beaHistory = note(await call("GET", "/owner/history/ctx-bea"));
521
+ assert.equal((beaHistory.data as Hist[]).some((r) => r.text === "revoked reply" || r.text === "pending reply" || r.text === "restored reply"), false, beaHistory.text);
522
+ assert.deepEqual(await snap("task-bea"), afterRestore);
523
+ }
524
+
525
+ hides(outputs.join("\n"), "response");
526
+ hides(logs.join("\n"), "log");
527
+ for (const secret of secrets) {
528
+ const hash = await sha256(secret);
529
+ assert.equal(outputs.join("\n").includes(hash), false, "response contains a token hash");
530
+ assert.equal(logs.join("\n").includes(hash), false, "log contains a token hash");
531
+ }
532
+ });
@@ -20,15 +20,25 @@ async function fixture(t: Parameters<typeof test>[1] extends (t: infer T) => unk
20
20
  };
21
21
  const byId = new Map<string, TenantContext>();
22
22
  let directPosts = 0;
23
+ const cards: string[] = [];
24
+ let missingCard = false;
23
25
  let hold: Promise<void> | null = null;
26
+ let posted: (() => void) | null = null;
27
+ let aborted = false;
24
28
  const ns: DoNamespace = {
25
29
  idFromName(name: string) { return name; },
26
30
  get(id: unknown) {
27
31
  return { async fetch(req: Request) {
28
32
  const ctx = byId.get(String(id));
29
33
  if (!ctx) return new Response("missing", { status: 404 });
34
+ if (req.method === "GET" && new URL(req.url).pathname.startsWith("/.well-known/agent")) {
35
+ cards.push(new URL(req.url).pathname);
36
+ if (missingCard) return new Response("missing", { status: 404 });
37
+ }
30
38
  if (req.method === "POST" && new URL(req.url).pathname === "/") {
31
39
  directPosts++;
40
+ posted?.();
41
+ req.signal.addEventListener("abort", () => { aborted = true; }, { once: true });
32
42
  if (hold) await hold;
33
43
  }
34
44
  return dispatch(req, ctx, ectx as never);
@@ -61,10 +71,11 @@ async function fixture(t: Parameters<typeof test>[1] extends (t: infer T) => unk
61
71
  assert.equal(r.status, 200, JSON.stringify(data));
62
72
  return data.result.id as string;
63
73
  };
64
- return { a, b, dbA, dbB, owner, sendToBob, get directPosts() { return directPosts; }, blockDirect() {
74
+ return { a, b, dbA, dbB, owner, sendToBob, cards, missingCard() { missingCard = true; }, get directPosts() { return directPosts; }, get aborted() { return aborted; }, blockDirect() {
65
75
  let release!: () => void;
66
76
  hold = new Promise<void>((resolve) => { release = resolve; });
67
- return () => { hold = null; release(); };
77
+ const started = new Promise<void>((resolve) => { posted = resolve; });
78
+ return Object.assign(() => { hold = null; posted = null; release(); }, { started });
68
79
  } };
69
80
  }
70
81
 
@@ -129,8 +140,9 @@ test("stale recovery never overlaps an uncertain delivery and a later retry read
129
140
  const s = await fixture(t as never);
130
141
  const taskId = await s.sendToBob("concurrent question", "ctx-concurrent");
131
142
  const release = s.blockDirect();
143
+ t.after(release);
132
144
  const first = s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "one answer" });
133
- while (s.directPosts === 0) await new Promise((resolve) => setTimeout(resolve, 0));
145
+ await release.started;
134
146
  await s.dbB.prepare("UPDATE reply_deliveries SET updated_at = '2000-01-01T00:00:00.000Z'").run();
135
147
  const raced = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "one answer" });
136
148
  assert.equal(raced.status, 200);
@@ -138,11 +150,48 @@ test("stale recovery never overlaps an uncertain delivery and a later retry read
138
150
  assert.match(String((raced.body as any).structuredContent.detail), /uncertain.*not sent again/);
139
151
  assert.equal(s.directPosts, 1, "the overlapping call cannot start a second delivery");
140
152
  release();
141
- assert.equal(((await first).body as any).structuredContent.status, "delivered");
153
+ assert.deepEqual(((await first).body as any).structuredContent, (raced.body as any).structuredContent, "a late success cannot replace the recorded uncertain result");
142
154
  const retry = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "one answer" });
143
155
  assert.equal(retry.status, 200);
144
- assert.equal((retry.body as any).structuredContent.status, "delivered");
156
+ assert.deepEqual((retry.body as any).structuredContent, (raced.body as any).structuredContent);
157
+ assert.equal(s.directPosts, 1);
158
+ });
159
+
160
+ test("an identical retry during delivery reports in-flight before the stored completed state", async (t) => {
161
+ const s = await fixture(t as never);
162
+ const taskId = await s.sendToBob("retry question", "ctx-in-flight");
163
+ const release = s.blockDirect();
164
+ t.after(release);
165
+ const first = s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "one answer" });
166
+ await release.started;
167
+ const retry = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "one answer" });
168
+ assert.equal(retry.status, 200);
169
+ assert.equal((retry.body as any).isError, true);
170
+ assert.match(String((retry.body as any).text), /already being delivered/);
171
+ assert.equal(s.directPosts, 1);
172
+ release();
173
+ assert.equal(((await first).body as any).structuredContent.status, "delivered");
174
+ });
175
+
176
+ test("the total delivery deadline returns while a peer ignores cancellation and retries never resend", async (t) => {
177
+ const s = await fixture(t as never);
178
+ const taskId = await s.sendToBob("slow question", "ctx-deadline");
179
+ const release = s.blockDirect();
180
+ t.after(release);
181
+ t.mock.timers.enable({ apis: ["setTimeout"] });
182
+ t.after(() => t.mock.timers.reset());
183
+ const first = s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "bounded answer" });
184
+ await release.started;
185
+ t.mock.timers.tick(18000);
186
+ t.mock.timers.reset();
187
+ const result = await first;
188
+ assert.equal(result.status, 200);
189
+ assert.equal((result.body as any).structuredContent.status, "delivery-failed");
190
+ assert.equal(s.aborted, true, "the deadline reaches the actual peer request signal");
191
+ const retry = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "bounded answer" });
192
+ assert.deepEqual((retry.body as any).structuredContent, (result.body as any).structuredContent);
145
193
  assert.equal(s.directPosts, 1);
194
+ release();
146
195
  });
147
196
 
148
197
  test("a remote token rejection reports HTTP status without losing the stored reply", async (t) => {
@@ -159,3 +208,14 @@ test("a remote token rejection reports HTTP status without losing the stored rep
159
208
  assert.equal(stored.state, "completed");
160
209
  assert.match(stored.status_json, /the answer stays/);
161
210
  });
211
+
212
+
213
+ test("reply discovers one canonical card and uses the paired base when it is missing", async (t) => {
214
+ const s = await fixture(t as never);
215
+ const taskId = await s.sendToBob("no card question", "ctx-one-card");
216
+ s.missingCard();
217
+ const result = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "stored answer" });
218
+ assert.equal((result.body as any).structuredContent.status, "delivered");
219
+ assert.deepEqual(s.cards, ["/.well-known/agent-card.json"]);
220
+ assert.equal(s.directPosts, 1);
221
+ });
@@ -320,3 +320,13 @@ test("public-https does not request a metadata address, and does post to a check
320
320
  assert.equal(tls.status, null);
321
321
  assert.equal(tls.info, "Error: TLS Handshake Failed.");
322
322
  });
323
+
324
+
325
+ test("an aborted peer request starts neither DNS lookup nor delivery", async () => {
326
+ const controller = new AbortController();
327
+ controller.abort(new Error("delivery deadline"));
328
+ await assert.rejects(() => fetchCheckedPeer("https://hook.example/rpc", { signal: controller.signal }, {
329
+ async lookup() { assert.fail("aborted request must not resolve DNS"); },
330
+ async deliver() { assert.fail("aborted request must not send"); },
331
+ }), /delivery deadline/);
332
+ });