a2a-exposed 0.8.17 → 0.8.19

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.
@@ -46,7 +46,7 @@ export type WakeConfig = {
46
46
 
47
47
  export type WakeRequest = { url: string; method: "POST"; headers: Record<string, string>; body: string };
48
48
 
49
- export const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "generic"] as const;
49
+ export const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "openbot", "generic"] as const;
50
50
 
51
51
  /** Default debounce window per preset (seconds), used when WAKE_DEBOUNCE_SECONDS is unset. */
52
52
  export function defaultDebounceSeconds(preset: string): number {
@@ -92,9 +92,11 @@ export function pairingInstructions(p: PairingInfo, cli: string): string {
92
92
 
93
93
  function hintFor(ev: WakeEvent, cli: string): string {
94
94
  if (ev.kind === "pairing_request") return `${cli} pair list`;
95
+ // `--from` keeps the read on this peer. The same context id can be another peer's separate conversation.
96
+ const from = /^[A-Za-z0-9_.-]{1,64}$/.test(ev.from) ? ` --from ${ev.from}` : "";
95
97
  return ev.kind === "outbound_update"
96
- ? `${cli} history ${ev.contextId}`
97
- : `${cli} inbox --context ${ev.contextId}`;
98
+ ? `${cli} history ${ev.contextId}${from}`
99
+ : `${cli} inbox --context ${ev.contextId}${from}`;
98
100
  }
99
101
 
100
102
  /** Human/agent-readable wake text. `withPreview=false` keeps peer-authored text out entirely. */
@@ -205,6 +207,16 @@ export async function renderWake(cfg: WakeConfig, ev: WakeEvent, opts: { nowMs?:
205
207
  // `name` is the hook name OpenClaw filters match. It stays "A2A inbox". The wake text itself starts with "A2A endpoint:".
206
208
  body = JSON.stringify({ message: wakeSummary(ev, cli), name: "A2A inbox", agentId: cfg.agentId || "main", sessionMode: "isolated", deliver: false });
207
209
  break;
210
+ case "openbot": {
211
+ const ts = String(Math.floor(nowMs / 1000));
212
+ headers["x-openbot-timestamp"] = ts;
213
+ headers["x-openbot-delivery-id"] = requestId;
214
+ // Serialize once: the signature covers exactly the UTF-8 bytes sent to OpenBot.
215
+ body = JSON.stringify({ type: "a2a_wake", occurredAt: new Date(nowMs).toISOString(), data: wakePayload(ev, cli) });
216
+ if (cfg.hmacSecret)
217
+ headers["x-openbot-signature"] = `sha256=${await hmacSha256Hex(cfg.hmacSecret, `${ts}.${requestId}.${body}`)}`;
218
+ break;
219
+ }
208
220
  case "hermes":
209
221
  headers["x-request-id"] = requestId;
210
222
  body = JSON.stringify(wakePayload(ev, cli));
@@ -266,7 +278,7 @@ export function cloudflareErrorHint(status: number, body: string, opts?: { tunne
266
278
 
267
279
  /** Headers a wake preview may show in full. Every other header carries a credential, whatever its name. */
268
280
  export const PREVIEW_PLAIN_HEADERS = new Set([
269
- "content-type", "user-agent", "x-request-id", "idempotency-key", "anthropic-version", "x-webhook-timestamp",
281
+ "content-type", "user-agent", "x-request-id", "idempotency-key", "anthropic-version", "x-webhook-timestamp", "x-openbot-timestamp", "x-openbot-delivery-id",
270
282
  ]);
271
283
 
272
284
  export function previewHeaderPlain(name: string): boolean {
@@ -1,4 +1,5 @@
1
- // A peer must not post into another peer's conversation by reusing its contextId.
1
+ // A context id is one conversation per peer. A client-supplied id this peer does not already hold
2
+ // starts that peer's own conversation, whether or not another peer already uses the same string.
2
3
  // Run: npm test
3
4
  import { test } from "node:test";
4
5
  import assert from "node:assert/strict";
@@ -6,7 +7,6 @@ import worker from "../src/index.ts";
6
7
  import { d1 } from "./d1.ts";
7
8
 
8
9
  const BASE = "https://agent.example.com";
9
- const UNKNOWN = "Invalid params: unknown contextId (omit contextId to start a new conversation, then use the one returned)";
10
10
 
11
11
  function setup() {
12
12
  const DB = d1(new URL("../migrations/", import.meta.url));
@@ -32,91 +32,191 @@ function setup() {
32
32
  jsonrpc: "2.0", id: 1, method,
33
33
  params: { message: { kind: "message", role: "user", messageId: crypto.randomUUID(), ...(contextId ? { contextId } : {}), parts: [{ kind: "text", text }] } },
34
34
  }, `Bearer ${token}`);
35
- return { DB, owner, send, restore: () => { globalThis.fetch = origFetch; } };
35
+ return { DB, owner, send, call, restore: () => { globalThis.fetch = origFetch; } };
36
36
  }
37
37
 
38
- test("another peer's contextId is refused without joining that conversation", async (t) => {
38
+ /** The 0.3 task, or the 1.0 `{ task }` wrapper, minus fields that are new on every call. */
39
+ function opened(data: any) {
40
+ const task = data?.result?.kind === "task" || data?.result?.status ? data.result : data?.result?.task;
41
+ const history = Array.isArray(task?.history) ? task.history : [];
42
+ return {
43
+ contextId: task?.contextId,
44
+ state: task?.status?.state,
45
+ texts: history.map((m: { parts?: { text?: string }[] }) => (m.parts || []).map((p) => p.text || "").join("\n")),
46
+ roles: history.map((m: { role?: string }) => m.role),
47
+ };
48
+ }
49
+
50
+ function hides(body: string, secret: string) {
51
+ assert.equal(body.includes(secret), false);
52
+ }
53
+
54
+ test("a foreign contextId opens the caller's own conversation and does not reveal the other one", async (t) => {
39
55
  const s = setup();
40
56
  t.after(s.restore);
41
57
  const ada = (await s.owner("POST", "/owner/peers", { label: "ada" })).data.token as string;
42
58
  const bob = (await s.owner("POST", "/owner/peers", { label: "bob" })).data.token as string;
43
59
  assert.match(ada, /^a2aow_/);
44
60
  assert.match(bob, /^a2aow_/);
61
+ const pushToken = `pt_${crypto.randomUUID()}`;
45
62
 
46
63
  const first = await s.send(ada, "y-one", "ctx-y");
47
- assert.equal(first.status, 200, first.text);
64
+ assert.equal(first.status, 200);
48
65
  assert.equal(first.data.result.contextId, "ctx-y");
49
66
  const second = await s.send(ada, "y-two", "ctx-y");
50
- assert.equal(second.status, 200, second.text);
67
+ assert.equal(second.status, 200);
51
68
  assert.equal(second.data.result.contextId, "ctx-y");
69
+ const adaTask = second.data.result.id as string;
70
+ s.DB.db.prepare("UPDATE tasks SET push_configs_json = ? WHERE id = ?").run(JSON.stringify([{ id: "cfg-ada", url: "https://hooks.example.net/ada", token: pushToken, _version: "0.3" }]), adaTask);
52
71
 
53
- const history = () => s.owner("GET", "/owner/history/ctx-y");
54
- const beforeHist = await history();
55
- assert.equal(beforeHist.status, 200, beforeHist.text);
56
- const beforeTexts = (beforeHist.data as { text: string }[]).map((r) => r.text);
57
- assert.deepEqual(beforeTexts, ["y-one", "y-two"]);
58
- const wake = () => s.DB.db.prepare("SELECT context_id, peer, last_sent_ms, pending_json, pending_since_ms FROM wakes WHERE context_id = 'ctx-y' AND peer = 'ada'").get() as {
72
+ const history = (from?: string) => s.owner("GET", `/owner/history/ctx-y${from ? `?from=${from}` : ""}`);
73
+ const beforeHist = await history("ada");
74
+ assert.equal(beforeHist.status, 200);
75
+ assert.deepEqual((beforeHist.data as { text: string; peer: string }[]).map((r) => [r.peer, r.text]), [["ada", "y-one"], ["ada", "y-two"]]);
76
+ const wake = (peer: string) => s.DB.db.prepare("SELECT context_id, peer, last_sent_ms, pending_json, pending_since_ms FROM wakes WHERE context_id = 'ctx-y' AND peer = ?").get(peer) as {
59
77
  pending_json: string; peer: string; last_sent_ms: number; pending_since_ms: number;
60
- };
61
- const beforeWake = wake();
62
- assert.equal(beforeWake.peer, "ada");
63
- const pending = JSON.parse(beforeWake.pending_json);
78
+ } | undefined;
79
+ const beforeWake = wake("ada");
80
+ assert.equal(beforeWake?.peer, "ada");
81
+ const pending = JSON.parse(beforeWake!.pending_json);
64
82
  assert.equal(pending.from, "ada");
65
83
  assert.equal(pending.preview, "y-two");
66
- assert.equal(pending.taskIds.includes(second.data.result.id), true);
84
+ assert.equal(pending.taskIds.includes(adaTask), true);
85
+
86
+ const listedForeign = await s.call("POST", "/", {
87
+ jsonrpc: "2.0", id: 1, method: "ListTasks", params: { contextId: "ctx-y", pageSize: 50 },
88
+ }, `Bearer ${bob}`);
89
+ const listedFresh = await s.call("POST", "/", {
90
+ jsonrpc: "2.0", id: 1, method: "ListTasks", params: { contextId: "ctx-never-seen", pageSize: 50 },
91
+ }, `Bearer ${bob}`);
92
+ assert.equal(listedForeign.status, 200);
93
+ assert.equal(listedFresh.status, 200);
94
+ assert.deepEqual(listedForeign.data.result, listedFresh.data.result);
95
+ assert.deepEqual(listedForeign.data.result, { tasks: [], nextPageToken: "", pageSize: 50, totalSize: 0 });
96
+ hides(listedForeign.text, "y-one");
97
+ hides(listedForeign.text, "y-two");
98
+ hides(listedForeign.text, pushToken);
99
+ hides(listedForeign.text, ada);
67
100
 
68
101
  const foreign = await s.send(bob, "x-inject", "ctx-y");
102
+ const fresh = await s.send(bob, "x-inject", "ctx-new");
103
+ assert.equal(foreign.status, 200);
104
+ assert.equal(fresh.status, 200);
105
+ assert.equal(foreign.data.error, undefined);
106
+ assert.equal(fresh.data.error, undefined);
107
+ assert.deepEqual(opened(foreign.data), { contextId: "ctx-y", state: "submitted", texts: ["x-inject"], roles: ["user"] });
108
+ assert.deepEqual(opened(fresh.data), { contextId: "ctx-new", state: "submitted", texts: ["x-inject"], roles: ["user"] });
109
+ hides(foreign.text, "y-one");
110
+ hides(foreign.text, "y-two");
111
+ hides(foreign.text, pushToken);
112
+ hides(foreign.text, ada);
113
+ hides(foreign.text, bob);
114
+
69
115
  const foreignV1 = await s.send(bob, "x-inject-v1", "ctx-y", "SendMessage");
70
- assert.deepEqual(foreign.data.error, { code: -32602, message: UNKNOWN });
71
- assert.deepEqual(foreignV1.data.error, foreign.data.error);
72
- const body = JSON.stringify(foreign.data);
73
- assert.equal(body.includes("ada"), false);
74
- assert.equal(body.includes("ctx-y"), false);
75
- assert.equal(body.includes("y-one"), false);
76
- assert.equal(body.includes("y-two"), false);
77
- assert.equal(body.includes("exists"), false);
78
- assert.equal(body.includes(ada), false);
79
- assert.equal(body.includes(bob), false);
80
-
81
- const afterHist = await history();
82
- assert.deepEqual(afterHist.data, beforeHist.data);
83
- assert.deepEqual(wake(), beforeWake);
116
+ assert.equal(foreignV1.status, 200);
117
+ assert.equal(foreignV1.data.result.task.contextId, "ctx-y");
118
+ assert.deepEqual((foreignV1.data.result.task.history || []).map((m: { parts: { text: string }[] }) => m.parts.map((p) => p.text).join("\n")), ["x-inject-v1"]);
119
+ hides(foreignV1.text, "y-one");
120
+ hides(foreignV1.text, pushToken);
121
+
122
+ const adaRows = await history("ada");
123
+ assert.deepEqual(adaRows.data, beforeHist.data);
124
+ assert.deepEqual(wake("ada"), beforeWake);
84
125
  const db = s.DB.db;
85
- assert.equal((db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE context_id = 'ctx-y' AND peer = 'bob'").get() as { n: number }).n, 0);
86
- assert.equal((db.prepare("SELECT COUNT(*) AS n FROM history WHERE context_id = 'ctx-y' AND peer = 'bob'").get() as { n: number }).n, 0);
87
- assert.equal((db.prepare("SELECT COUNT(*) AS n FROM wakes WHERE context_id = 'ctx-y' AND peer = 'bob'").get() as { n: number }).n, 0);
88
- assert.equal((db.prepare("SELECT COUNT(*) AS n FROM messages WHERE context_id = 'ctx-y' AND message_json LIKE '%x-inject%'").get() as { n: number }).n, 0);
126
+ assert.equal((db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE context_id = 'ctx-y' AND peer = 'ada'").get() as { n: number }).n, 2);
127
+ assert.equal((db.prepare("SELECT COUNT(*) AS n FROM messages WHERE context_id = 'ctx-y' AND message_json LIKE '%y-one%'").get() as { n: number }).n, 1);
128
+ assert.equal((db.prepare("SELECT COUNT(*) AS n FROM messages WHERE task_id = ? AND message_json LIKE '%x-inject%'").get(adaTask) as { n: number }).n, 0);
129
+ const bobWake = wake("bob");
130
+ assert.equal(bobWake?.peer, "bob");
131
+ assert.equal(JSON.parse(bobWake!.pending_json).from, "bob");
132
+ assert.equal(JSON.parse(bobWake!.pending_json).preview, "x-inject-v1");
133
+ assert.equal(JSON.parse(beforeWake!.pending_json).preview, "y-two");
89
134
 
90
- assert.equal((db.prepare("SELECT peer FROM conversation_owners WHERE context_id = 'ctx-y'").get() as { peer: string }).peer, "ada");
135
+ const owners = db.prepare("SELECT peer FROM conversation_owners WHERE context_id = 'ctx-y' ORDER BY peer").all() as { peer: string }[];
136
+ assert.deepEqual(owners.map((r) => r.peer), ["ada", "bob"]);
137
+
138
+ const steal = await s.call("POST", "/", {
139
+ jsonrpc: "2.0", id: 1, method: "message/send",
140
+ params: { message: { kind: "message", role: "user", messageId: crypto.randomUUID(), taskId: adaTask, contextId: "ctx-y", parts: [{ kind: "text", text: "steal" }] } },
141
+ }, `Bearer ${bob}`);
142
+ assert.deepEqual(steal.data.error, { code: -32001, message: "Task not found" });
143
+ hides(steal.text, "y-one");
144
+ hides(steal.text, pushToken);
145
+ const got = await s.call("POST", "/", { jsonrpc: "2.0", id: 1, method: "tasks/get", params: { id: adaTask } }, `Bearer ${bob}`);
146
+ assert.deepEqual(got.data.error, { code: -32001, message: "Task not found" });
147
+ hides(got.text, pushToken);
148
+ hides(got.text, "y-two");
91
149
 
92
150
  const follow = await s.send(ada, "y-three", "ctx-y");
93
- assert.equal(follow.status, 200, follow.text);
151
+ assert.equal(follow.status, 200);
94
152
  assert.equal(follow.data.result.contextId, "ctx-y");
95
- const texts = ((await history()).data as { text: string; peer: string }[]).map((r) => r.text);
96
- assert.deepEqual(texts, ["y-one", "y-two", "y-three"]);
97
- const after = JSON.parse(wake().pending_json);
153
+ assert.deepEqual(((await history("ada")).data as { text: string; peer: string }[]).map((r) => r.text), ["y-one", "y-two", "y-three"]);
154
+ assert.equal(((await history("ada")).data as { peer: string }[]).every((r) => r.peer === "ada"), true);
155
+ const bobOnly = (await history("bob")).data as { text: string; peer: string }[];
156
+ assert.deepEqual(bobOnly.map((r) => r.text), ["x-inject", "x-inject-v1"]);
157
+ assert.equal(bobOnly.every((r) => r.peer === "bob"), true);
158
+ const mixed = (await history()).data as { text: string; peer: string }[];
159
+ assert.deepEqual(mixed.map((r) => r.peer), ["ada", "ada", "bob", "bob", "ada"]);
160
+ const after = JSON.parse(wake("ada")!.pending_json);
98
161
  assert.equal(after.from, "ada");
99
162
  assert.equal(after.preview, "y-three");
100
163
  assert.equal(String(after.preview).includes("x-inject"), false);
101
- assert.equal(after.taskIds.includes(follow.data.result.id), true);
102
- assert.equal(after.taskIds.includes(second.data.result.id), true);
164
+
165
+ const adaInbox = await s.owner("GET", "/owner/inbox?context=ctx-y&from=ada&all=1");
166
+ const bobInbox = await s.owner("GET", "/owner/inbox?context=ctx-y&from=bob&all=1");
167
+ assert.equal((adaInbox.data as { from: string; text: string }[]).every((r) => r.from === "ada"), true);
168
+ assert.equal((bobInbox.data as { from: string; text: string }[]).every((r) => r.from === "bob"), true);
169
+ assert.equal((adaInbox.data as { text: string }[]).some((r) => r.text === "x-inject"), false);
170
+ assert.equal((bobInbox.data as { text: string }[]).some((r) => r.text === "y-one"), false);
171
+
172
+ const adaList = await s.call("POST", "/", { jsonrpc: "2.0", id: 1, method: "ListTasks", params: { contextId: "ctx-y" } }, `Bearer ${ada}`);
173
+ const bobList = await s.call("POST", "/", { jsonrpc: "2.0", id: 1, method: "ListTasks", params: { contextId: "ctx-y" } }, `Bearer ${bob}`);
174
+ assert.equal(adaList.data.result.totalSize, 3);
175
+ assert.equal(bobList.data.result.totalSize, 2);
176
+ assert.equal((adaList.data.result.tasks as { id: string }[]).some((t) => t.id === adaTask), true);
177
+ assert.equal((bobList.data.result.tasks as { id: string }[]).some((t) => t.id === adaTask), false);
178
+ hides(bobList.text, pushToken);
179
+ hides(bobList.text, "y-one");
103
180
 
104
181
  const own = await s.send(bob, "x-own", "ctx-x");
105
- assert.equal(own.status, 200, own.text);
182
+ assert.equal(own.status, 200);
106
183
  assert.equal(own.data.result.contextId, "ctx-x");
107
184
  const again = await s.send(bob, "x-again", "ctx-x");
108
- assert.equal(again.status, 200, again.text);
185
+ assert.equal(again.status, 200);
109
186
  assert.equal(again.data.result.contextId, "ctx-x");
110
- const bobHist = await s.owner("GET", "/owner/history/ctx-x");
187
+ const omitted = await s.send(bob, "x-omit");
188
+ assert.equal(omitted.status, 200);
189
+ assert.match(omitted.data.result.contextId, /^[0-9a-f-]{36}$/);
190
+ assert.equal(omitted.data.result.contextId === "ctx-y", false);
191
+ const bobHist = await s.owner("GET", "/owner/history/ctx-x?from=bob");
111
192
  assert.deepEqual((bobHist.data as { text: string }[]).map((r) => r.text), ["x-own", "x-again"]);
112
- assert.deepEqual(((await history()).data as { text: string }[]).map((r) => r.text), ["y-one", "y-two", "y-three"]);
113
- assert.equal(JSON.parse(wake().pending_json).from, "ada");
114
- assert.equal(JSON.parse(wake().pending_json).preview, "y-three");
115
- assert.equal((db.prepare("SELECT peer FROM conversation_owners WHERE context_id = 'ctx-x'").get() as { peer: string }).peer, "bob");
116
- assert.equal((db.prepare("SELECT COUNT(*) AS n FROM conversation_owners WHERE context_id = 'ctx-y'").get() as { n: number }).n, 1);
117
193
  });
118
194
 
119
- test("two peers cannot both claim one unused contextId", async (t) => {
195
+ test("an outbound row on the same context id is not joined into the other peer's task", async (t) => {
196
+ const s = setup();
197
+ t.after(s.restore);
198
+ const ada = (await s.owner("POST", "/owner/peers", { label: "ada" })).data.token as string;
199
+ const bob = (await s.owner("POST", "/owner/peers", { label: "bob" })).data.token as string;
200
+ const aliasText = "alias-outbound-secret";
201
+ await s.send(ada, "y-one", "ctx-y");
202
+ const db = s.DB.db;
203
+ db.prepare("INSERT INTO outbound (task_id, context_id, peer, sent_at, updates_json) VALUES ('out-1', 'ctx-y', 'alias', '2026-01-01T00:00:00.000Z', '[]')").run();
204
+ db.prepare("INSERT INTO history (context_id, ts, dir, peer, task_id, text) VALUES ('ctx-y', '2026-01-01T00:00:00.000Z', 'in', 'alias', 'out-1', ?)").run(aliasText);
205
+
206
+ const sent = await s.send(bob, "from-bob", "ctx-y");
207
+ assert.equal(sent.status, 200);
208
+ assert.deepEqual(opened(sent.data).texts, ["from-bob"]);
209
+ hides(sent.text, aliasText);
210
+ const bobHist = await s.owner("GET", "/owner/history/ctx-y?from=bob");
211
+ assert.equal((bobHist.data as { text: string }[]).some((r) => r.text === aliasText), false);
212
+ const adaHist = await s.owner("GET", "/owner/history/ctx-y?from=ada");
213
+ assert.equal((adaHist.data as { text: string }[]).some((r) => r.text === aliasText), false);
214
+ assert.deepEqual((adaHist.data as { text: string }[]).map((r) => r.text), ["y-one"]);
215
+ assert.equal((db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE context_id = 'ctx-y' AND peer = 'ada'").get() as { n: number }).n, 1);
216
+ assert.equal((db.prepare("SELECT text FROM history WHERE task_id = 'out-1'").get() as { text: string }).text, aliasText);
217
+ });
218
+
219
+ test("two peers can both open one unused contextId without sharing history or wakes", async (t) => {
120
220
  const s = setup();
121
221
  t.after(s.restore);
122
222
  const ada = (await s.owner("POST", "/owner/peers", { label: "ada" })).data.token as string;
@@ -138,34 +238,53 @@ test("two peers cannot both claim one unused contextId", async (t) => {
138
238
  s.send(bob, "from-bob", "ctx-race"),
139
239
  ]);
140
240
  assert.equal(arrived, 2);
141
- const ok = [left, right].filter((r) => r.status === 200 && !r.data.error);
142
- const denied = [left, right].filter((r) => r.data?.error);
143
- assert.equal(ok.length, 1, JSON.stringify([left.data, right.data]));
144
- assert.equal(denied.length, 1);
145
- assert.deepEqual(denied[0].data.error, { code: -32602, message: UNKNOWN });
146
- assert.equal(ok[0].data.result.contextId, "ctx-race");
147
- const body = JSON.stringify(denied[0].data);
148
- for (const secret of ["ada", "bob", "ctx-race", "from-ada", "from-bob", "exists", ada, bob]) assert.equal(body.includes(secret), false, secret);
241
+ assert.equal(left.status, 200);
242
+ assert.equal(right.status, 200);
243
+ assert.equal(left.data.error, undefined);
244
+ assert.equal(right.data.error, undefined);
245
+ assert.equal(left.data.result.contextId, "ctx-race");
246
+ assert.equal(right.data.result.contextId, "ctx-race");
247
+ assert.deepEqual(opened(left.data).texts, ["from-ada"]);
248
+ assert.deepEqual(opened(right.data).texts, ["from-bob"]);
249
+ hides(left.text, "from-bob");
250
+ hides(right.text, "from-ada");
251
+ hides(left.text, bob);
252
+ hides(right.text, ada);
149
253
 
150
254
  const db = s.DB.db;
151
- const owners = db.prepare("SELECT peer FROM conversation_owners WHERE context_id = 'ctx-race'").all() as { peer: string }[];
152
- assert.equal(owners.length, 1);
153
- const winner = owners[0].peer;
154
- const loser = winner === "ada" ? "bob" : "ada";
155
- assert.equal(winner === "ada" || winner === "bob", true);
156
- assert.equal((db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE context_id = 'ctx-race'").get() as { n: number }).n, 1);
157
- assert.equal((db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE context_id = 'ctx-race' AND peer = ?").get(loser) as { n: number }).n, 0);
158
- assert.equal((db.prepare("SELECT COUNT(*) AS n FROM history WHERE context_id = 'ctx-race' AND peer = ?").get(loser) as { n: number }).n, 0);
159
- assert.equal((db.prepare("SELECT COUNT(*) AS n FROM messages WHERE context_id = 'ctx-race' AND message_json LIKE ?").get(`%from-${loser}%`) as { n: number }).n, 0);
160
- assert.equal((db.prepare("SELECT COUNT(*) AS n FROM wakes WHERE context_id = 'ctx-race' AND peer = ?").get(loser) as { n: number }).n, 0);
161
-
162
- const follow = await s.send(winner === "ada" ? ada : bob, "follow", "ctx-race");
163
- assert.equal(follow.status, 200, follow.text);
164
- assert.equal(follow.data.result.contextId, "ctx-race");
165
- const hist = await s.owner("GET", "/owner/history/ctx-race");
166
- assert.deepEqual((hist.data as { text: string; peer: string }[]).map((r) => r.text), [winner === "ada" ? "from-ada" : "from-bob", "follow"]);
167
- assert.equal((hist.data as { peer: string }[]).every((r) => r.peer === winner), true);
168
- const again = await s.send(loser === "ada" ? ada : bob, "still-no", "ctx-race");
169
- assert.deepEqual(again.data.error, { code: -32602, message: UNKNOWN });
170
- assert.equal((db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE context_id = 'ctx-race' AND peer = ?").get(loser) as { n: number }).n, 0);
255
+ const owners = db.prepare("SELECT peer FROM conversation_owners WHERE context_id = 'ctx-race' ORDER BY peer").all() as { peer: string }[];
256
+ assert.deepEqual(owners.map((r) => r.peer), ["ada", "bob"]);
257
+ assert.equal((db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE context_id = 'ctx-race'").get() as { n: number }).n, 2);
258
+ assert.equal((db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE context_id = 'ctx-race' AND peer = 'ada'").get() as { n: number }).n, 1);
259
+ assert.equal((db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE context_id = 'ctx-race' AND peer = 'bob'").get() as { n: number }).n, 1);
260
+ assert.equal((db.prepare("SELECT COUNT(*) AS n FROM history WHERE context_id = 'ctx-race' AND peer = 'ada' AND text = 'from-bob'").get() as { n: number }).n, 0);
261
+ assert.equal((db.prepare("SELECT COUNT(*) AS n FROM history WHERE context_id = 'ctx-race' AND peer = 'bob' AND text = 'from-ada'").get() as { n: number }).n, 0);
262
+ assert.equal((db.prepare("SELECT COUNT(*) AS n FROM messages WHERE context_id = 'ctx-race' AND message_json LIKE '%from-ada%' AND message_json LIKE '%from-bob%'").get() as { n: number }).n, 0);
263
+ assert.equal((db.prepare("SELECT COUNT(*) AS n FROM wakes WHERE context_id = 'ctx-race'").get() as { n: number }).n, 2);
264
+ const wakeRow = (peer: string) => db.prepare("SELECT pending_json, last_sent_ms FROM wakes WHERE context_id = 'ctx-race' AND peer = ?").get(peer) as { pending_json: string | null; last_sent_ms: number };
265
+ // The first message of a conversation is sent at once, so neither row is holding the other's text.
266
+ assert.equal(wakeRow("ada").pending_json, null);
267
+ assert.equal(wakeRow("bob").pending_json, null);
268
+ assert.equal(wakeRow("ada").last_sent_ms > 0 && wakeRow("bob").last_sent_ms > 0, true);
269
+
270
+ const followAda = await s.send(ada, "follow-ada", "ctx-race");
271
+ const adaPendingJson = wakeRow("ada").pending_json;
272
+ const followBob = await s.send(bob, "follow-bob", "ctx-race");
273
+ assert.equal(followAda.data.result.contextId, "ctx-race");
274
+ assert.equal(followBob.data.result.contextId, "ctx-race");
275
+ assert.equal(wakeRow("ada").pending_json, adaPendingJson);
276
+ const adaPending = JSON.parse(adaPendingJson || "null");
277
+ const bobPending = JSON.parse(wakeRow("bob").pending_json || "null");
278
+ assert.equal(adaPending.preview, "follow-ada");
279
+ assert.equal(bobPending.preview, "follow-bob");
280
+ assert.equal(adaPending.taskIds.includes(right.data.result.id), false);
281
+ assert.equal(adaPending.taskIds.includes(followBob.data.result.id), false);
282
+ assert.equal(bobPending.taskIds.includes(left.data.result.id), false);
283
+ assert.equal(bobPending.taskIds.includes(followAda.data.result.id), false);
284
+ const adaHist = await s.owner("GET", "/owner/history/ctx-race?from=ada");
285
+ const bobHist = await s.owner("GET", "/owner/history/ctx-race?from=bob");
286
+ assert.deepEqual((adaHist.data as { text: string; peer: string }[]).map((r) => r.text), ["from-ada", "follow-ada"]);
287
+ assert.deepEqual((bobHist.data as { text: string; peer: string }[]).map((r) => r.text), ["from-bob", "follow-bob"]);
288
+ assert.equal((adaHist.data as { peer: string }[]).every((r) => r.peer === "ada"), true);
289
+ assert.equal((bobHist.data as { peer: string }[]).every((r) => r.peer === "bob"), true);
171
290
  });
@@ -326,7 +326,9 @@ test("proxy mode: peers sharing the upstream identity can't touch each other's t
326
326
  for (const [method, params] of [
327
327
  ["GetTask", { id: taskId }], ["CancelTask", { id: taskId }], ["tasks/get", { id: taskId }],
328
328
  ["GetTaskPushNotificationConfig", { taskId, id: "x" }], ["ListTaskPushNotificationConfig", { taskId }],
329
- ["DeleteTaskPushNotificationConfig", { taskId, id: "x" }], ["GetTask", { id: "never-seen" }],
329
+ ["ListTaskPushNotificationConfigs", { taskId }], ["DeleteTaskPushNotificationConfig", { taskId, id: "x" }],
330
+ ["tasks/pushNotificationConfig/list", { id: taskId }], ["tasks/pushNotificationConfig/delete", { id: taskId, pushNotificationConfigId: "x" }],
331
+ ["GetTask", { id: "never-seen" }],
330
332
  ] as const) {
331
333
  const x = await s.rpc(b, method, params);
332
334
  assert.equal(x.data.error?.code, -32001, `${method}: ${JSON.stringify(x.data)}`);
@@ -390,7 +392,9 @@ test("proxy mode: push configs never reach the upstream (it would call them from
390
392
  ["CreateTaskPushNotificationConfig", { taskId, url: "https://hooks.example.org/b" }],
391
393
  ["tasks/pushNotificationConfig/set", { taskId, pushNotificationConfig: { url: "https://hooks.example.org/b" } }],
392
394
  ["GetTaskPushNotificationConfig", { taskId, id: "x" }], ["ListTaskPushNotificationConfig", { taskId }],
393
- ["DeleteTaskPushNotificationConfig", { taskId, id: "x" }], ["tasks/pushNotificationConfig/get", { id: taskId }],
395
+ ["ListTaskPushNotificationConfigs", { taskId }], ["DeleteTaskPushNotificationConfig", { taskId, id: "x" }],
396
+ ["tasks/pushNotificationConfig/list", { id: taskId }], ["tasks/pushNotificationConfig/delete", { id: taskId, pushNotificationConfigId: "x" }],
397
+ ["tasks/pushNotificationConfig/get", { id: taskId }],
394
398
  ] as const) {
395
399
  const r = await s.rpc(a, method, params);
396
400
  assert.equal(r.data.error?.code, -32003, method);
@@ -0,0 +1,120 @@
1
+ // ListTasks returns a cursor. The page is this peer's tasks, newest update first. Run: npm test
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import worker from "../src/index.ts";
5
+ import { d1 } from "./d1.ts";
6
+
7
+ const BASE = "https://agent.example.com";
8
+
9
+ function setup() {
10
+ const DB = d1(new URL("../migrations/", import.meta.url));
11
+ const env: any = {
12
+ DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", AGENT_NAME: "Test Inbox", WAKE_PRESET: "generic",
13
+ };
14
+ const call = async (token: string, method: string, params: unknown) => {
15
+ const res = await worker.fetch(new Request(BASE + "/", {
16
+ method: "POST",
17
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
18
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
19
+ }) as any, env, { waitUntil() {}, passThroughOnException() {} } as any);
20
+ const text = await res.text();
21
+ let data: any = text;
22
+ try { data = text ? JSON.parse(text) : null; } catch { /* not json */ }
23
+ return { status: res.status, data, text };
24
+ };
25
+ const owner = async (method: string, path: string, json?: unknown) => {
26
+ const res = await worker.fetch(new Request(BASE + path, {
27
+ method, headers: { authorization: "Bearer owner-secret", "content-type": "application/json" },
28
+ body: json === undefined ? undefined : JSON.stringify(json),
29
+ }) as any, env, { waitUntil() {}, passThroughOnException() {} } as any);
30
+ return res.json() as Promise<any>;
31
+ };
32
+ return { DB, call, owner };
33
+ }
34
+
35
+ function stamp(n: number): string {
36
+ return `2026-01-01T00:00:${String(n).padStart(2, "0")}.000Z`;
37
+ }
38
+
39
+ function decodeCursor(token: string): { updatedAt: string; id: string } {
40
+ const pad = token.length % 4 === 0 ? "" : "=".repeat(4 - (token.length % 4));
41
+ const text = Buffer.from(token.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString();
42
+ const nl = text.indexOf("\n");
43
+ return { updatedAt: text.slice(0, nl), id: text.slice(nl + 1) };
44
+ }
45
+
46
+ test("ListTasks pages with nextPageToken and stays on the caller", async () => {
47
+ const s = setup();
48
+ const ada = (await s.owner("POST", "/owner/peers", { label: "ada" })).token as string;
49
+ const bob = (await s.owner("POST", "/owner/peers", { label: "bob" })).token as string;
50
+ const insert = s.DB.db.prepare(`INSERT INTO tasks (id, context_id, peer, state, protocol, created_at, updated_at, status_json, artifacts_json, push_configs_json)
51
+ VALUES (?, 'ctx-page', ?, 'submitted', '1.0', ?, ?, ?, '[]', '[]')`);
52
+ const ids: string[] = [];
53
+ for (let n = 1; n <= 16; n++) {
54
+ const id = `t-${String(n).padStart(2, "0")}`;
55
+ ids.push(id);
56
+ // t-16 and t-15 share a timestamp so the tie breaks on id descending.
57
+ const at = stamp(n === 15 ? 16 : n);
58
+ insert.run(id, "ada", at, at, JSON.stringify({ state: "submitted", timestamp: at }));
59
+ }
60
+ insert.run("t-bob", "bob", stamp(30), stamp(30), JSON.stringify({ state: "submitted", timestamp: stamp(30) }));
61
+ const expected = [...ids].reverse();
62
+ assert.deepEqual(expected.slice(0, 2), ["t-16", "t-15"]);
63
+
64
+ const seen: string[] = [];
65
+ let token = "";
66
+ let pages = 0;
67
+ let total = -1;
68
+ do {
69
+ const page = await s.call(ada, "ListTasks", { pageSize: 5, ...(token ? { pageToken: token } : {}) });
70
+ assert.equal(page.status, 200);
71
+ assert.equal(page.data.error, undefined);
72
+ const result = page.data.result;
73
+ assert.equal(result.pageSize, 5);
74
+ assert.equal(result.totalSize, 16);
75
+ total = result.totalSize;
76
+ const pageIds = (result.tasks as { id: string; contextId: string }[]).map((task) => task.id);
77
+ assert.equal(pageIds.includes("t-bob"), false);
78
+ assert.equal((result.tasks as { contextId: string }[]).every((task) => task.contextId === "ctx-page"), true);
79
+ if (result.nextPageToken) {
80
+ const cursor = decodeCursor(result.nextPageToken);
81
+ assert.equal(cursor.id, pageIds[pageIds.length - 1]);
82
+ assert.equal(result.tasks.length, 5);
83
+ } else {
84
+ assert.equal(result.nextPageToken, "");
85
+ }
86
+ seen.push(...pageIds);
87
+ token = result.nextPageToken;
88
+ pages++;
89
+ } while (token);
90
+ assert.equal(pages, 4);
91
+ assert.equal(total, 16);
92
+ assert.deepEqual(seen, expected);
93
+ assert.equal(new Set(seen).size, 16);
94
+
95
+ const bobPage = await s.call(bob, "ListTasks", { pageSize: 5 });
96
+ assert.deepEqual((bobPage.data.result.tasks as { id: string }[]).map((task) => task.id), ["t-bob"]);
97
+ assert.equal(bobPage.data.result.totalSize, 1);
98
+ assert.equal(bobPage.data.result.nextPageToken, "");
99
+
100
+ const filtered = await s.call(ada, "ListTasks", { contextId: "ctx-page", pageSize: 100 });
101
+ assert.equal(filtered.data.result.totalSize, 16);
102
+ assert.equal(filtered.data.result.pageSize, 100);
103
+ assert.equal(filtered.data.result.nextPageToken, "");
104
+ const empty = await s.call(ada, "ListTasks", { contextId: "ctx-none" });
105
+ assert.deepEqual(empty.data.result, { tasks: [], nextPageToken: "", pageSize: 50, totalSize: 0 });
106
+ const badContext = await s.call(ada, "ListTasks", { contextId: "bad id" });
107
+ assert.equal(badContext.data.error?.code, -32602);
108
+
109
+ const bad = "page-token-secret-value";
110
+ const rejected = await s.call(ada, "ListTasks", { pageToken: bad });
111
+ assert.deepEqual(rejected.data.error, { code: -32602, message: "Invalid params: pageToken" });
112
+ assert.equal(rejected.text.includes(bad), false);
113
+ const garbage = Buffer.from("not-a-cursor", "utf8").toString("base64url");
114
+ const rejectedGarbage = await s.call(ada, "ListTasks", { pageToken: garbage });
115
+ assert.equal(rejectedGarbage.data.error?.message, "Invalid params: pageToken");
116
+ assert.equal(rejectedGarbage.text.includes(garbage), false);
117
+
118
+ const absent = await s.call(ada, "tasks/list", {});
119
+ assert.equal(absent.data.error?.code, -32601);
120
+ });
@@ -67,15 +67,15 @@ test("fresh database: every migration applies in order; a second run applies not
67
67
  test("database from the 0002 fix (0001-0003 recorded): only 0004 and later apply; pending requests keep working", () => {
68
68
  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
69
  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"]);
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"]);
71
71
  assert.equal((db.prepare("SELECT replaces_label FROM device_requests WHERE device_hash = 'h'").get() as any).replaces_label, null);
72
72
  });
73
73
 
74
74
  test("database from v0.2.0 (0001 and 0003 recorded): 0002 applies as a no-op and keeps the data", () => {
75
75
  const db = existingDb([sql("0001_init.sql"), sql("0003_device_pairing.sql")], ["0001_init.sql", "0003_device_pairing.sql"]);
76
76
  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"]);
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"]);
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"]);
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"]);
79
79
  assert.equal((db.prepare("SELECT count FROM wake_budget WHERE hour = 1").get() as any).count, 7);
80
80
  });
81
81
 
@@ -107,7 +107,7 @@ test("database from an earlier build without wake_budget (0001 and 0003 recorded
107
107
  assert.equal(before.data.error?.code, -32603, JSON.stringify(before.data));
108
108
  assert.equal(wakes.length, 0);
109
109
 
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"]);
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"]);
111
111
  assert.deepEqual(columns(db, "wake_budget"), ["hour", "count"]);
112
112
  assert.deepEqual(applyMigrations(db), []);
113
113
 
@@ -141,7 +141,7 @@ test("0010 keys an existing wake by the peer in its payload and allows a second
141
141
  });
142
142
 
143
143
  test("0011 keeps one owner per context from the earliest task, then history, then outbound", () => {
144
- const prior = [...FILES].sort(byNumber).filter((f) => f !== "0011_conversation_owner.sql");
144
+ const prior = [...FILES].sort(byNumber).filter((f) => f !== "0011_conversation_owner.sql" && f !== "0012_conversation_peer_owner.sql");
145
145
  const db = existingDb(prior.map(sql), prior);
146
146
  const task = `INSERT INTO tasks (id, context_id, peer, state, protocol, created_at, updated_at, status_json, artifacts_json, push_configs_json)
147
147
  VALUES (?, ?, ?, 'submitted', '0.3', ?, ?, '{}', '[]', '[]')`;
@@ -149,7 +149,7 @@ test("0011 keeps one owner per context from the earliest task, then history, the
149
149
  db.prepare(task).run("t-ada", "ctx-y", "ada", "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z");
150
150
  db.prepare("INSERT INTO history (context_id, ts, dir, peer, text) VALUES ('ctx-h', '2026-01-01T00:00:00.000Z', 'local', 'ada', 'note')").run();
151
151
  db.prepare("INSERT INTO outbound (task_id, context_id, peer, sent_at) VALUES ('o1', 'ctx-o', 'alias', '2026-01-01T00:00:00.000Z')").run();
152
- assert.deepEqual(applyMigrations(db), ["0011_conversation_owner.sql"]);
152
+ assert.deepEqual(applyMigrations(db, ["0011_conversation_owner.sql"]), ["0011_conversation_owner.sql"]);
153
153
  const owner = (id: string) => (db.prepare("SELECT peer FROM conversation_owners WHERE context_id = ?").get(id) as { peer: string }).peer;
154
154
  assert.equal(owner("ctx-y"), "ada");
155
155
  assert.equal((db.prepare("SELECT COUNT(*) AS n FROM conversation_owners WHERE context_id = 'ctx-y'").get() as { n: number }).n, 1);
@@ -157,3 +157,15 @@ test("0011 keeps one owner per context from the earliest task, then history, the
157
157
  assert.equal(owner("ctx-o"), "alias");
158
158
  assert.throws(() => db.prepare("INSERT INTO conversation_owners (context_id, peer, created_at) VALUES ('ctx-y', 'bob', 'later')").run());
159
159
  });
160
+
161
+ test("0012 lets each peer own the same context id and keeps the first row", () => {
162
+ const prior = [...FILES].sort(byNumber).filter((f) => f !== "0012_conversation_peer_owner.sql");
163
+ const db = existingDb(prior.map(sql), prior);
164
+ db.prepare("INSERT INTO conversation_owners (context_id, peer, created_at) VALUES ('ctx-y', 'ada', '2026-01-01T00:00:00.000Z')").run();
165
+ assert.throws(() => db.prepare("INSERT INTO conversation_owners (context_id, peer, created_at) VALUES ('ctx-y', 'bob', 'later')").run());
166
+ assert.deepEqual(applyMigrations(db), ["0012_conversation_peer_owner.sql"]);
167
+ assert.equal((db.prepare("SELECT created_at FROM conversation_owners WHERE context_id = 'ctx-y' AND peer = 'ada'").get() as { created_at: string }).created_at, "2026-01-01T00:00:00.000Z");
168
+ db.prepare("INSERT INTO conversation_owners (context_id, peer, created_at) VALUES ('ctx-y', 'bob', 'later')").run();
169
+ const peers = db.prepare("SELECT peer FROM conversation_owners WHERE context_id = 'ctx-y' ORDER BY peer").all() as { peer: string }[];
170
+ assert.deepEqual(peers.map((r) => r.peer), ["ada", "bob"]);
171
+ });