a2a-exposed 0.8.16 → 0.8.18

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,290 @@
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.
3
+ // Run: npm test
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import worker from "../src/index.ts";
7
+ import { d1 } from "./d1.ts";
8
+
9
+ const BASE = "https://agent.example.com";
10
+
11
+ function setup() {
12
+ const DB = d1(new URL("../migrations/", import.meta.url));
13
+ const env: any = {
14
+ DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", AGENT_NAME: "Test Inbox", WAKE_PRESET: "generic",
15
+ WAKE_DEBOUNCE_SECONDS: "3600", WAKE_WEBHOOK_URL: "https://hook.example.net/wake", WAKE_WEBHOOK_KEY: "hook-key",
16
+ };
17
+ const origFetch = globalThis.fetch;
18
+ globalThis.fetch = (async () => new Response("ok")) as typeof fetch;
19
+ const call = async (method: string, path: string, json?: unknown, auth = "Bearer owner-secret") => {
20
+ const headers: Record<string, string> = { authorization: auth, "content-type": "application/json", "cf-connecting-ip": "198.51.100.7" };
21
+ const pending: Promise<unknown>[] = [];
22
+ const res = await worker.fetch(new Request(BASE + path, { method, headers, body: json === undefined ? undefined : JSON.stringify(json) }) as any,
23
+ env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
24
+ await Promise.allSettled(pending);
25
+ const text = await res.text();
26
+ let data: any = text;
27
+ try { data = text ? JSON.parse(text) : null; } catch { /* not json */ }
28
+ return { status: res.status, data, text };
29
+ };
30
+ const owner = (method: string, path: string, json?: unknown) => call(method, path, json);
31
+ const send = (token: string, text: string, contextId?: string, method = "message/send") => call("POST", "/", {
32
+ jsonrpc: "2.0", id: 1, method,
33
+ params: { message: { kind: "message", role: "user", messageId: crypto.randomUUID(), ...(contextId ? { contextId } : {}), parts: [{ kind: "text", text }] } },
34
+ }, `Bearer ${token}`);
35
+ return { DB, owner, send, call, restore: () => { globalThis.fetch = origFetch; } };
36
+ }
37
+
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) => {
55
+ const s = setup();
56
+ t.after(s.restore);
57
+ const ada = (await s.owner("POST", "/owner/peers", { label: "ada" })).data.token as string;
58
+ const bob = (await s.owner("POST", "/owner/peers", { label: "bob" })).data.token as string;
59
+ assert.match(ada, /^a2aow_/);
60
+ assert.match(bob, /^a2aow_/);
61
+ const pushToken = `pt_${crypto.randomUUID()}`;
62
+
63
+ const first = await s.send(ada, "y-one", "ctx-y");
64
+ assert.equal(first.status, 200);
65
+ assert.equal(first.data.result.contextId, "ctx-y");
66
+ const second = await s.send(ada, "y-two", "ctx-y");
67
+ assert.equal(second.status, 200);
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);
71
+
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 {
77
+ pending_json: string; peer: string; last_sent_ms: number; pending_since_ms: number;
78
+ } | undefined;
79
+ const beforeWake = wake("ada");
80
+ assert.equal(beforeWake?.peer, "ada");
81
+ const pending = JSON.parse(beforeWake!.pending_json);
82
+ assert.equal(pending.from, "ada");
83
+ assert.equal(pending.preview, "y-two");
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);
100
+
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
+
115
+ const foreignV1 = await s.send(bob, "x-inject-v1", "ctx-y", "SendMessage");
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);
125
+ const db = s.DB.db;
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");
134
+
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");
149
+
150
+ const follow = await s.send(ada, "y-three", "ctx-y");
151
+ assert.equal(follow.status, 200);
152
+ assert.equal(follow.data.result.contextId, "ctx-y");
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);
161
+ assert.equal(after.from, "ada");
162
+ assert.equal(after.preview, "y-three");
163
+ assert.equal(String(after.preview).includes("x-inject"), false);
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");
180
+
181
+ const own = await s.send(bob, "x-own", "ctx-x");
182
+ assert.equal(own.status, 200);
183
+ assert.equal(own.data.result.contextId, "ctx-x");
184
+ const again = await s.send(bob, "x-again", "ctx-x");
185
+ assert.equal(again.status, 200);
186
+ assert.equal(again.data.result.contextId, "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");
192
+ assert.deepEqual((bobHist.data as { text: string }[]).map((r) => r.text), ["x-own", "x-again"]);
193
+ });
194
+
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) => {
220
+ const s = setup();
221
+ t.after(s.restore);
222
+ const ada = (await s.owner("POST", "/owner/peers", { label: "ada" })).data.token as string;
223
+ const bob = (await s.owner("POST", "/owner/peers", { label: "bob" })).data.token as string;
224
+ let arrived = 0;
225
+ let openGate = () => {};
226
+ const gate = new Promise<void>((resolve) => { openGate = resolve; });
227
+ const orig = s.DB.batch.bind(s.DB);
228
+ s.DB.batch = async (stmts: { __sql?: string }[]) => {
229
+ const claim = stmts.some((st) => typeof st.__sql === "string" && st.__sql.includes("INSERT INTO conversation_owners"));
230
+ if (!claim || arrived >= 2) return orig(stmts);
231
+ arrived++;
232
+ if (arrived === 2) openGate();
233
+ await gate;
234
+ return orig(stmts);
235
+ };
236
+ const [left, right] = await Promise.all([
237
+ s.send(ada, "from-ada", "ctx-race"),
238
+ s.send(bob, "from-bob", "ctx-race"),
239
+ ]);
240
+ assert.equal(arrived, 2);
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);
253
+
254
+ const db = s.DB.db;
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);
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
+ });
@@ -57,7 +57,7 @@ test("migration files: NNNN_name.sql, unique numbers, no gaps, 0002_wake_budget
57
57
  test("fresh database: every migration applies in order; a second run applies nothing", () => {
58
58
  const db = new DatabaseSync(":memory:");
59
59
  assert.deepEqual(applyMigrations(db), [...FILES].sort(byNumber));
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"]) assert.ok(tables(db).includes(t), t);
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"]) assert.ok(tables(db).includes(t), t);
61
61
  assert.ok(columns(db, "peers").includes("source"));
62
62
  assert.ok(columns(db, "device_requests").includes("replaces_label"));
63
63
  assert.ok(columns(db, "device_requests").includes("replaces_hash"));
@@ -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"]);
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"]);
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"]);
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"]);
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
 
@@ -122,3 +122,50 @@ test("database from an earlier build without wake_budget (0001 and 0003 recorded
122
122
  globalThis.fetch = origFetch;
123
123
  }
124
124
  });
125
+
126
+ test("0010 keys an existing wake by the peer in its payload and allows a second peer", () => {
127
+ const prior = [...FILES].sort(byNumber).filter((f) => f !== "0010_conversation_peer.sql");
128
+ const db = existingDb(prior.map(sql), prior);
129
+ db.prepare(`INSERT INTO tasks (id, context_id, peer, state, protocol, created_at, updated_at, status_json, artifacts_json, push_configs_json)
130
+ VALUES ('t1', 'ctx-y', 'ada', 'submitted', '0.3', 't', 't', '{}', '[]', '[]')`).run();
131
+ const pending = JSON.stringify({ contextId: "ctx-y", from: "ada", taskId: "t1", taskIds: ["t1"], kind: "inbound", preview: "hi" });
132
+ db.prepare("INSERT INTO wakes (context_id, last_sent_ms, pending_json, pending_since_ms) VALUES ('ctx-y', 5, ?, 5)").run(pending);
133
+ assert.deepEqual(applyMigrations(db), ["0010_conversation_peer.sql"]);
134
+ const row = db.prepare("SELECT peer, pending_json, last_sent_ms FROM wakes WHERE context_id = 'ctx-y'").get() as { peer: string; pending_json: string; last_sent_ms: number };
135
+ assert.equal(row.peer, "ada");
136
+ assert.equal(row.last_sent_ms, 5);
137
+ assert.equal(JSON.parse(row.pending_json).preview, "hi");
138
+ db.prepare("INSERT INTO wakes (context_id, peer, last_sent_ms) VALUES ('ctx-y', 'bob', 6)").run();
139
+ assert.equal((db.prepare("SELECT COUNT(*) AS n FROM wakes WHERE context_id = 'ctx-y'").get() as { n: number }).n, 2);
140
+ assert.equal((db.prepare("SELECT pending_json FROM wakes WHERE context_id = 'ctx-y' AND peer = 'ada'").get() as { pending_json: string }).pending_json, pending);
141
+ });
142
+
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" && f !== "0012_conversation_peer_owner.sql");
145
+ const db = existingDb(prior.map(sql), prior);
146
+ const task = `INSERT INTO tasks (id, context_id, peer, state, protocol, created_at, updated_at, status_json, artifacts_json, push_configs_json)
147
+ VALUES (?, ?, ?, 'submitted', '0.3', ?, ?, '{}', '[]', '[]')`;
148
+ db.prepare(task).run("t-bob", "ctx-y", "bob", "2026-02-01T00:00:00.000Z", "2026-02-01T00:00:00.000Z");
149
+ db.prepare(task).run("t-ada", "ctx-y", "ada", "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z");
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
+ 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"]), ["0011_conversation_owner.sql"]);
153
+ const owner = (id: string) => (db.prepare("SELECT peer FROM conversation_owners WHERE context_id = ?").get(id) as { peer: string }).peer;
154
+ assert.equal(owner("ctx-y"), "ada");
155
+ assert.equal((db.prepare("SELECT COUNT(*) AS n FROM conversation_owners WHERE context_id = 'ctx-y'").get() as { n: number }).n, 1);
156
+ assert.equal(owner("ctx-h"), "ada");
157
+ assert.equal(owner("ctx-o"), "alias");
158
+ assert.throws(() => db.prepare("INSERT INTO conversation_owners (context_id, peer, created_at) VALUES ('ctx-y', 'bob', 'later')").run());
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
+ });
@@ -12,9 +12,11 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
12
12
  assert.equal(ok("GET", "/owner/inbox")?.name, "GET /owner/inbox");
13
13
  assert.equal(ok("GET", "/owner/summary")?.name, "GET /owner/summary");
14
14
  assert.equal(ok("GET", "/owner/inbox", { context: "ctx-1", all: "1" })?.search, "?context=ctx-1&all=1");
15
+ assert.equal(ok("GET", "/owner/inbox", { from: "ada" })?.search, "?from=ada");
15
16
  assert.equal(ok("GET", "/owner/inbox", { limit: "20", cursor: "abc_DEF-012" })?.search, "?limit=20&cursor=abc_DEF-012");
16
17
  assert.equal(ok("GET", "/owner/tasks/task-1")?.path, "/owner/tasks/task-1");
17
18
  assert.equal(ok("GET", "/owner/history/ctx-1", { n: "50" })?.search, "?n=50");
19
+ assert.equal(ok("GET", "/owner/history/ctx-1", { n: "50", from: "ada" })?.search, "?n=50&from=ada");
18
20
  assert.equal(ok("GET", "/owner/outbound/task-1")?.name, "GET /owner/outbound/:id");
19
21
  assert.equal(ok("GET", "/owner/peers")?.name, "GET /owner/peers");
20
22
  assert.equal(ok("DELETE", "/owner/peers/ada")?.path, "/owner/peers/ada");
@@ -63,6 +65,8 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
63
65
  assert.deepEqual(refused("GET", "/owner/inbox", { limit: "201" }), { ok: false, reason: "query" });
64
66
  assert.deepEqual(refused("GET", "/owner/inbox", { limit: "0" }), { ok: false, reason: "query" });
65
67
  assert.deepEqual(refused("GET", "/owner/inbox", { cursor: "bad+cursor" }), { ok: false, reason: "query" });
68
+ assert.deepEqual(refused("GET", "/owner/inbox", { from: "bad peer" }), { ok: false, reason: "query" });
69
+ assert.deepEqual(refused("GET", "/owner/history/ctx-1", { from: "../x" }), { ok: false, reason: "query" });
66
70
  assert.deepEqual(refused("GET", "/owner/summary", { all: "1" }), { ok: false, reason: "query" });
67
71
  assert.deepEqual(refused("GET", "/owner/history/ctx-1", { n: "1001" }), { ok: false, reason: "query" });
68
72
  assert.deepEqual(refused("GET", "/owner/history/.."), { ok: false, reason: "path" });