agent-inbox 0.2.2 → 0.2.3
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.
- package/bench/inbox-growth.bench.ts +224 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -1
- package/dist/index.js.map +1 -1
- package/dist/jsonrpc/mail-push-types.d.ts +9 -0
- package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
- package/dist/jsonrpc/mail-push-types.js +1 -0
- package/dist/jsonrpc/mail-push-types.js.map +1 -1
- package/dist/jsonrpc/mail-server.d.ts +8 -1
- package/dist/jsonrpc/mail-server.d.ts.map +1 -1
- package/dist/jsonrpc/mail-server.js +42 -1
- package/dist/jsonrpc/mail-server.js.map +1 -1
- package/dist/push/notifier.d.ts +21 -0
- package/dist/push/notifier.d.ts.map +1 -1
- package/dist/push/notifier.js +84 -2
- package/dist/push/notifier.js.map +1 -1
- package/dist/storage/interface.d.ts +12 -0
- package/dist/storage/interface.d.ts.map +1 -1
- package/dist/storage/memory.d.ts +8 -0
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +38 -0
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite.d.ts +8 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +51 -1
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/traceability/traceability.d.ts.map +1 -1
- package/dist/traceability/traceability.js +7 -17
- package/dist/traceability/traceability.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/index.ts +38 -1
- package/src/jsonrpc/mail-push-types.ts +10 -0
- package/src/jsonrpc/mail-server.ts +48 -1
- package/src/push/notifier.ts +98 -2
- package/src/storage/interface.ts +11 -0
- package/src/storage/memory.ts +44 -0
- package/src/storage/sqlite.ts +78 -1
- package/src/traceability/traceability.ts +7 -16
- package/src/types.ts +1 -0
- package/test/load.test.ts +288 -0
- package/test/mail-presence.test.ts +149 -0
- package/test/mail-push.test.ts +44 -0
- package/test/mail-server.test.ts +25 -0
- package/test/push-notifier.test.ts +81 -0
- package/test/sqlite-storage.test.ts +106 -0
- package/test/storage.test.ts +92 -0
- package/vitest.bench.config.ts +8 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { EventEmitter } from "node:events";
|
|
6
|
+
import { InMemoryStorage } from "../src/storage/memory.js";
|
|
7
|
+
import { SqliteStorage } from "../src/storage/sqlite.js";
|
|
8
|
+
import { MessageRouter } from "../src/router/message-router.js";
|
|
9
|
+
import { TraceabilityLayer } from "../src/traceability/traceability.js";
|
|
10
|
+
import { PushNotifier } from "../src/push/notifier.js";
|
|
11
|
+
import type { Storage } from "../src/storage/interface.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load tests verifying the file-growth and write-amplification fixes.
|
|
15
|
+
*
|
|
16
|
+
* These tests run larger volumes (1k-10k messages) so they're slower than
|
|
17
|
+
* the unit suite but still finish in seconds. They assert on counts of
|
|
18
|
+
* side-effects (writes, rows, file size) — not wall-clock time — so they
|
|
19
|
+
* are deterministic.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface CallCounts {
|
|
23
|
+
putMessage: number;
|
|
24
|
+
setMessageConversationId: number;
|
|
25
|
+
putConversation: number;
|
|
26
|
+
touchConversation: number;
|
|
27
|
+
addParticipant: number;
|
|
28
|
+
addTurn: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function instrumentStorage(inner: Storage): { storage: Storage; counts: CallCounts } {
|
|
32
|
+
const counts: CallCounts = {
|
|
33
|
+
putMessage: 0,
|
|
34
|
+
setMessageConversationId: 0,
|
|
35
|
+
putConversation: 0,
|
|
36
|
+
touchConversation: 0,
|
|
37
|
+
addParticipant: 0,
|
|
38
|
+
addTurn: 0,
|
|
39
|
+
};
|
|
40
|
+
const storage = new Proxy(inner, {
|
|
41
|
+
get(target, prop, receiver) {
|
|
42
|
+
const value = Reflect.get(target, prop, receiver);
|
|
43
|
+
if (typeof value !== "function") return value;
|
|
44
|
+
if (prop in counts) {
|
|
45
|
+
return (...args: unknown[]) => {
|
|
46
|
+
counts[prop as keyof CallCounts]++;
|
|
47
|
+
return (value as Function).apply(target, args);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return (value as Function).bind(target);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
return { storage, counts };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("load: inbox file caps stay bounded under heavy writes", () => {
|
|
57
|
+
let tmpDir: string;
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "inbox-load-"));
|
|
61
|
+
});
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should stay near maxEntries (soft cap ~1.1×) after 10000 writes", async () => {
|
|
67
|
+
const cap = 500;
|
|
68
|
+
const storage = new InMemoryStorage();
|
|
69
|
+
const events = new EventEmitter();
|
|
70
|
+
const router = new MessageRouter(storage, events, "default");
|
|
71
|
+
const notifier = new PushNotifier(
|
|
72
|
+
{ inboxDir: tmpDir, maxEntriesPerInbox: cap, maxBytesPerInbox: 0 },
|
|
73
|
+
storage,
|
|
74
|
+
events
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < 10_000; i++) {
|
|
78
|
+
await router.routeMessage({
|
|
79
|
+
from: "alice",
|
|
80
|
+
to: "bob",
|
|
81
|
+
payload: `msg-${i}`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const filePath = notifier.agentInboxPath("bob");
|
|
86
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
87
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
88
|
+
expect(lines.length).toBeLessThanOrEqual(Math.ceil(cap * 1.1));
|
|
89
|
+
expect(lines.length).toBeGreaterThanOrEqual(cap);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should stay near maxBytes (soft cap ~1.1×) after 10000 writes", async () => {
|
|
93
|
+
const cap = 64 * 1024;
|
|
94
|
+
const storage = new InMemoryStorage();
|
|
95
|
+
const events = new EventEmitter();
|
|
96
|
+
const router = new MessageRouter(storage, events, "default");
|
|
97
|
+
const notifier = new PushNotifier(
|
|
98
|
+
{ inboxDir: tmpDir, maxEntriesPerInbox: 0, maxBytesPerInbox: cap },
|
|
99
|
+
storage,
|
|
100
|
+
events
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < 10_000; i++) {
|
|
104
|
+
await router.routeMessage({
|
|
105
|
+
from: "alice",
|
|
106
|
+
to: "bob",
|
|
107
|
+
payload: `msg-${i} ${"x".repeat(50)}`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const size = fs.statSync(notifier.agentInboxPath("bob")).size;
|
|
112
|
+
expect(size).toBeLessThanOrEqual(Math.ceil(cap * 1.1));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("would grow unboundedly with caps disabled (control)", async () => {
|
|
116
|
+
const storage = new InMemoryStorage();
|
|
117
|
+
const events = new EventEmitter();
|
|
118
|
+
const router = new MessageRouter(storage, events, "default");
|
|
119
|
+
const notifier = new PushNotifier(
|
|
120
|
+
{ inboxDir: tmpDir, maxEntriesPerInbox: 0, maxBytesPerInbox: 0 },
|
|
121
|
+
storage,
|
|
122
|
+
events
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const N = 2000;
|
|
126
|
+
for (let i = 0; i < N; i++) {
|
|
127
|
+
await router.routeMessage({
|
|
128
|
+
from: "alice",
|
|
129
|
+
to: "bob",
|
|
130
|
+
payload: `msg-${i}`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const raw = fs.readFileSync(notifier.agentInboxPath("bob"), "utf-8");
|
|
135
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
136
|
+
expect(lines).toHaveLength(N);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("load: traceability does not amplify writes", () => {
|
|
141
|
+
it("should call setMessageConversationId once per message and never re-putMessage", async () => {
|
|
142
|
+
const inner = new InMemoryStorage();
|
|
143
|
+
const { storage, counts } = instrumentStorage(inner);
|
|
144
|
+
const events = new EventEmitter();
|
|
145
|
+
const router = new MessageRouter(storage, events, "default");
|
|
146
|
+
new TraceabilityLayer(storage, events);
|
|
147
|
+
|
|
148
|
+
const N = 1000;
|
|
149
|
+
for (let i = 0; i < N; i++) {
|
|
150
|
+
await router.routeMessage({
|
|
151
|
+
from: "alice",
|
|
152
|
+
to: "bob",
|
|
153
|
+
payload: `msg-${i}`,
|
|
154
|
+
threadTag: "load-test",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Router does the only putMessage. Traceability now uses the targeted
|
|
159
|
+
// setMessageConversationId path, so total putMessage calls === N.
|
|
160
|
+
expect(counts.putMessage).toBe(N);
|
|
161
|
+
expect(counts.setMessageConversationId).toBe(N);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("should call touchConversation per message instead of full putConversation", async () => {
|
|
165
|
+
const inner = new InMemoryStorage();
|
|
166
|
+
const { storage, counts } = instrumentStorage(inner);
|
|
167
|
+
const events = new EventEmitter();
|
|
168
|
+
const router = new MessageRouter(storage, events, "default");
|
|
169
|
+
new TraceabilityLayer(storage, events);
|
|
170
|
+
|
|
171
|
+
const N = 1000;
|
|
172
|
+
for (let i = 0; i < N; i++) {
|
|
173
|
+
await router.routeMessage({
|
|
174
|
+
from: "alice",
|
|
175
|
+
to: "bob",
|
|
176
|
+
payload: `msg-${i}`,
|
|
177
|
+
threadTag: "load-test",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Exactly one putConversation (the create), then N touchConversation bumps.
|
|
182
|
+
expect(counts.putConversation).toBe(1);
|
|
183
|
+
expect(counts.touchConversation).toBe(N);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("should keep participant row count stable for a long-lived broadcast conversation", async () => {
|
|
187
|
+
const storage = new SqliteStorage({ path: ":memory:" });
|
|
188
|
+
const events = new EventEmitter();
|
|
189
|
+
const router = new MessageRouter(storage, events, "default");
|
|
190
|
+
new TraceabilityLayer(storage, events);
|
|
191
|
+
|
|
192
|
+
const recipients = Array.from({ length: 10 }, (_, i) => `agent-${i}`);
|
|
193
|
+
const N = 1000;
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i < N; i++) {
|
|
196
|
+
await router.routeMessage({
|
|
197
|
+
from: "alice",
|
|
198
|
+
to: recipients,
|
|
199
|
+
payload: `msg-${i}`,
|
|
200
|
+
threadTag: "broadcast",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const conversations = storage.listConversations("default");
|
|
205
|
+
expect(conversations).toHaveLength(1);
|
|
206
|
+
|
|
207
|
+
// alice + 10 recipients = 11 participants — must not balloon with N.
|
|
208
|
+
expect(conversations[0].participants).toHaveLength(11);
|
|
209
|
+
|
|
210
|
+
// Sanity: turns table grows linearly with messages (expected — that's the audit log).
|
|
211
|
+
expect(storage.getTurns(conversations[0].id)).toHaveLength(N);
|
|
212
|
+
|
|
213
|
+
storage.close();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("load: SQLite stays bounded", () => {
|
|
218
|
+
let dbPath: string;
|
|
219
|
+
|
|
220
|
+
beforeEach(() => {
|
|
221
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "inbox-sqlite-load-"));
|
|
222
|
+
dbPath = path.join(tmp, "inbox.db");
|
|
223
|
+
});
|
|
224
|
+
afterEach(() => {
|
|
225
|
+
const dir = path.dirname(dbPath);
|
|
226
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("should keep DB file growth bounded under heavy load", async () => {
|
|
230
|
+
const storage = new SqliteStorage({ path: dbPath });
|
|
231
|
+
const events = new EventEmitter();
|
|
232
|
+
const router = new MessageRouter(storage, events, "default");
|
|
233
|
+
new TraceabilityLayer(storage, events);
|
|
234
|
+
|
|
235
|
+
const recipients = Array.from({ length: 10 }, (_, i) => `agent-${i}`);
|
|
236
|
+
const N = 5000;
|
|
237
|
+
|
|
238
|
+
for (let i = 0; i < N; i++) {
|
|
239
|
+
await router.routeMessage({
|
|
240
|
+
from: "alice",
|
|
241
|
+
to: recipients,
|
|
242
|
+
payload: `msg-${i}`,
|
|
243
|
+
threadTag: "broadcast",
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// After close, WAL checkpoints into the main DB. The fix's main impact
|
|
248
|
+
// is on write *churn* (no per-message participant resyncs); this is a
|
|
249
|
+
// smoke test that the on-disk size scales reasonably with the data.
|
|
250
|
+
storage.close();
|
|
251
|
+
const size = fs.statSync(dbPath).size;
|
|
252
|
+
// 5000 messages × 11 recipients ≈ 55k recipient rows + 5k turns + FTS.
|
|
253
|
+
// 4 KiB per logical message is a generous ceiling.
|
|
254
|
+
expect(size).toBeLessThan(N * 4096);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("pruneMessagesOlderThan should remove old data and reduce row counts", async () => {
|
|
258
|
+
const storage = new SqliteStorage({ path: ":memory:" });
|
|
259
|
+
const events = new EventEmitter();
|
|
260
|
+
const router = new MessageRouter(storage, events, "default");
|
|
261
|
+
new TraceabilityLayer(storage, events);
|
|
262
|
+
|
|
263
|
+
const N = 1000;
|
|
264
|
+
for (let i = 0; i < N; i++) {
|
|
265
|
+
const old = i < N / 2;
|
|
266
|
+
const ts = old ? "2024-01-01T00:00:00Z" : "2026-12-01T00:00:00Z";
|
|
267
|
+
const msg = await router.routeMessage({
|
|
268
|
+
from: "alice",
|
|
269
|
+
to: "bob",
|
|
270
|
+
payload: `msg-${i}`,
|
|
271
|
+
threadTag: "load",
|
|
272
|
+
});
|
|
273
|
+
// Backdate by overriding via storage to simulate aged messages
|
|
274
|
+
const stored = storage.getMessage(msg.id)!;
|
|
275
|
+
stored.created_at = ts;
|
|
276
|
+
storage.putMessage(stored);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const removed = storage.pruneMessagesOlderThan("2025-01-01T00:00:00Z");
|
|
280
|
+
expect(removed).toBe(N / 2);
|
|
281
|
+
|
|
282
|
+
// Recent half remains
|
|
283
|
+
const conv = storage.listConversations("default")[0];
|
|
284
|
+
expect(storage.getTurns(conv.id)).toHaveLength(N / 2);
|
|
285
|
+
|
|
286
|
+
storage.close();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the mail/presence JSON-RPC method.
|
|
3
|
+
*
|
|
4
|
+
* Verifies participant presence resolution from an optional registry,
|
|
5
|
+
* error handling for missing conversations, and fallback to 'unknown'
|
|
6
|
+
* when no registry is provided.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
10
|
+
import { EventEmitter } from "node:events";
|
|
11
|
+
import { InMemoryStorage } from "../src/storage/memory.js";
|
|
12
|
+
import { MessageRouter } from "../src/router/message-router.js";
|
|
13
|
+
import { MailJsonRpcServer } from "../src/jsonrpc/mail-server.js";
|
|
14
|
+
|
|
15
|
+
describe("mail/presence", () => {
|
|
16
|
+
let storage: InMemoryStorage;
|
|
17
|
+
let events: EventEmitter;
|
|
18
|
+
let router: MessageRouter;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
storage = new InMemoryStorage();
|
|
22
|
+
events = new EventEmitter();
|
|
23
|
+
router = new MessageRouter(storage, events, "default");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function rpc(server: MailJsonRpcServer, method: string, params: Record<string, unknown> = {}) {
|
|
27
|
+
return server.handleRequest({
|
|
28
|
+
jsonrpc: "2.0",
|
|
29
|
+
id: 1,
|
|
30
|
+
method,
|
|
31
|
+
params,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function seedConversationWithParticipants(server: MailJsonRpcServer) {
|
|
36
|
+
const createResp = await rpc(server, "mail/create", {
|
|
37
|
+
subject: "Dispatch thread",
|
|
38
|
+
scope: "dispatch-thread",
|
|
39
|
+
});
|
|
40
|
+
const convId = (createResp.result as { id: string }).id;
|
|
41
|
+
|
|
42
|
+
await rpc(server, "mail/invite", {
|
|
43
|
+
conversationId: convId,
|
|
44
|
+
agentId: "user_abc",
|
|
45
|
+
role: "initiator",
|
|
46
|
+
});
|
|
47
|
+
await rpc(server, "mail/invite", {
|
|
48
|
+
conversationId: convId,
|
|
49
|
+
agentId: "executor-1",
|
|
50
|
+
role: "executor",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return convId;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
it("returns participants with presence from registry", async () => {
|
|
57
|
+
const registry = {
|
|
58
|
+
getStatus(agentId: string): string {
|
|
59
|
+
if (agentId === "user_abc") return "active";
|
|
60
|
+
if (agentId === "executor-1") return "away";
|
|
61
|
+
return "unknown";
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
const server = new MailJsonRpcServer(storage, router, events, registry);
|
|
65
|
+
const convId = await seedConversationWithParticipants(server);
|
|
66
|
+
|
|
67
|
+
const resp = await rpc(server, "mail/presence", { conversationId: convId });
|
|
68
|
+
|
|
69
|
+
expect(resp.error).toBeUndefined();
|
|
70
|
+
const result = resp.result as {
|
|
71
|
+
conversationId: string;
|
|
72
|
+
participants: Array<{ agent_id: string; role: string; presence: string }>;
|
|
73
|
+
};
|
|
74
|
+
expect(result.conversationId).toBe(convId);
|
|
75
|
+
expect(result.participants).toHaveLength(2);
|
|
76
|
+
|
|
77
|
+
const user = result.participants.find((p) => p.agent_id === "user_abc")!;
|
|
78
|
+
expect(user.role).toBe("initiator");
|
|
79
|
+
expect(user.presence).toBe("active");
|
|
80
|
+
|
|
81
|
+
const executor = result.participants.find((p) => p.agent_id === "executor-1")!;
|
|
82
|
+
expect(executor.role).toBe("executor");
|
|
83
|
+
expect(executor.presence).toBe("away");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns 'unknown' presence when no registry is provided", async () => {
|
|
87
|
+
const server = new MailJsonRpcServer(storage, router, events);
|
|
88
|
+
const convId = await seedConversationWithParticipants(server);
|
|
89
|
+
|
|
90
|
+
const resp = await rpc(server, "mail/presence", { conversationId: convId });
|
|
91
|
+
|
|
92
|
+
const result = resp.result as {
|
|
93
|
+
participants: Array<{ agent_id: string; presence: string }>;
|
|
94
|
+
};
|
|
95
|
+
expect(result.participants).toHaveLength(2);
|
|
96
|
+
expect(result.participants.every((p) => p.presence === "unknown")).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns error for non-existent conversation", async () => {
|
|
100
|
+
const server = new MailJsonRpcServer(storage, router, events);
|
|
101
|
+
|
|
102
|
+
const resp = await rpc(server, "mail/presence", {
|
|
103
|
+
conversationId: "nonexistent",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(resp.error).toBeDefined();
|
|
107
|
+
expect(resp.error!.code).toBe(-32001);
|
|
108
|
+
expect(resp.error!.message).toContain("not found");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns error when conversationId is missing", async () => {
|
|
112
|
+
const server = new MailJsonRpcServer(storage, router, events);
|
|
113
|
+
|
|
114
|
+
const resp = await rpc(server, "mail/presence", {});
|
|
115
|
+
|
|
116
|
+
expect(resp.error).toBeDefined();
|
|
117
|
+
expect(resp.error!.code).toBe(-32602);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns empty participants for conversation with no members", async () => {
|
|
121
|
+
const server = new MailJsonRpcServer(storage, router, events);
|
|
122
|
+
const createResp = await rpc(server, "mail/create", {
|
|
123
|
+
subject: "Empty thread",
|
|
124
|
+
});
|
|
125
|
+
const convId = (createResp.result as { id: string }).id;
|
|
126
|
+
|
|
127
|
+
const resp = await rpc(server, "mail/presence", { conversationId: convId });
|
|
128
|
+
|
|
129
|
+
const result = resp.result as {
|
|
130
|
+
participants: Array<{ agent_id: string }>;
|
|
131
|
+
};
|
|
132
|
+
expect(result.participants).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("includes joined_at timestamp for each participant", async () => {
|
|
136
|
+
const server = new MailJsonRpcServer(storage, router, events);
|
|
137
|
+
const convId = await seedConversationWithParticipants(server);
|
|
138
|
+
|
|
139
|
+
const resp = await rpc(server, "mail/presence", { conversationId: convId });
|
|
140
|
+
|
|
141
|
+
const result = resp.result as {
|
|
142
|
+
participants: Array<{ agent_id: string; joined_at: string }>;
|
|
143
|
+
};
|
|
144
|
+
for (const p of result.participants) {
|
|
145
|
+
expect(p.joined_at).toBeDefined();
|
|
146
|
+
expect(typeof p.joined_at).toBe("string");
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
package/test/mail-push.test.ts
CHANGED
|
@@ -200,6 +200,38 @@ describe("createMailPushBridge", () => {
|
|
|
200
200
|
expect(sendNotification).not.toHaveBeenCalled();
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
it("includes importance in params when turn has importance set", () => {
|
|
204
|
+
const events = new EventEmitter();
|
|
205
|
+
const sendNotification = vi.fn();
|
|
206
|
+
|
|
207
|
+
createMailPushBridge({
|
|
208
|
+
mailEvents: events,
|
|
209
|
+
getSubscribers: () => [{ id: "swarm-1" }],
|
|
210
|
+
sendNotification,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
events.emit("mail.turn.added", makeTurn({ importance: "high" }));
|
|
214
|
+
expect(sendNotification).toHaveBeenCalledOnce();
|
|
215
|
+
const params = sendNotification.mock.calls[0][2];
|
|
216
|
+
expect(params.importance).toBe("high");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("omits importance from params when turn has no importance", () => {
|
|
220
|
+
const events = new EventEmitter();
|
|
221
|
+
const sendNotification = vi.fn();
|
|
222
|
+
|
|
223
|
+
createMailPushBridge({
|
|
224
|
+
mailEvents: events,
|
|
225
|
+
getSubscribers: () => [{ id: "swarm-1" }],
|
|
226
|
+
sendNotification,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
events.emit("mail.turn.added", makeTurn());
|
|
230
|
+
expect(sendNotification).toHaveBeenCalledOnce();
|
|
231
|
+
const params = sendNotification.mock.calls[0][2];
|
|
232
|
+
expect(params).not.toHaveProperty("importance");
|
|
233
|
+
});
|
|
234
|
+
|
|
203
235
|
it("stop() is idempotent", () => {
|
|
204
236
|
const events = new EventEmitter();
|
|
205
237
|
const bridge = createMailPushBridge({
|
|
@@ -212,3 +244,15 @@ describe("createMailPushBridge", () => {
|
|
|
212
244
|
expect(() => bridge.stop()).not.toThrow();
|
|
213
245
|
});
|
|
214
246
|
});
|
|
247
|
+
|
|
248
|
+
describe("buildMailTurnReceivedParams", () => {
|
|
249
|
+
it("maps importance from Turn when present", () => {
|
|
250
|
+
const params = buildMailTurnReceivedParams(makeTurn({ importance: "urgent" }));
|
|
251
|
+
expect(params.importance).toBe("urgent");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("omits importance when Turn has no importance", () => {
|
|
255
|
+
const params = buildMailTurnReceivedParams(makeTurn());
|
|
256
|
+
expect(params).not.toHaveProperty("importance");
|
|
257
|
+
});
|
|
258
|
+
});
|
package/test/mail-server.test.ts
CHANGED
|
@@ -94,6 +94,31 @@ describe("MailJsonRpcServer", () => {
|
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
describe("mail/reopen", () => {
|
|
98
|
+
it("should reopen a closed conversation", async () => {
|
|
99
|
+
const createResp = await rpc("mail/create", { subject: "Test" });
|
|
100
|
+
const convId = (createResp.result as { id: string }).id;
|
|
101
|
+
|
|
102
|
+
// Close first
|
|
103
|
+
await rpc("mail/close", { id: convId });
|
|
104
|
+
expect(storage.getConversation(convId)!.status).toBe("completed");
|
|
105
|
+
|
|
106
|
+
// Reopen
|
|
107
|
+
const reopenResp = await rpc("mail/reopen", { id: convId });
|
|
108
|
+
const result = reopenResp.result as { conversationId: string; status: string };
|
|
109
|
+
expect(result.conversationId).toBe(convId);
|
|
110
|
+
expect(result.status).toBe("active");
|
|
111
|
+
|
|
112
|
+
const conv = storage.getConversation(convId)!;
|
|
113
|
+
expect(conv.status).toBe("active");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("should error on non-existent conversation", async () => {
|
|
117
|
+
const resp = await rpc("mail/reopen", { id: "nonexistent" });
|
|
118
|
+
expect(resp.error).toBeDefined();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
97
122
|
describe("mail/join & mail/leave", () => {
|
|
98
123
|
it("should add and remove participants", async () => {
|
|
99
124
|
const createResp = await rpc("mail/create", { subject: "Test" });
|
|
@@ -84,6 +84,87 @@ describe("PushNotifier", () => {
|
|
|
84
84
|
});
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
describe("PushNotifier inbox file caps", () => {
|
|
88
|
+
let capDir: string;
|
|
89
|
+
let capStorage: InMemoryStorage;
|
|
90
|
+
let capEvents: EventEmitter;
|
|
91
|
+
let capRouter: MessageRouter;
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
capDir = fs.mkdtempSync(path.join(os.tmpdir(), "inbox-cap-test-"));
|
|
95
|
+
capStorage = new InMemoryStorage();
|
|
96
|
+
capEvents = new EventEmitter();
|
|
97
|
+
capRouter = new MessageRouter(capStorage, capEvents, "default");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
fs.rmSync(capDir, { recursive: true, force: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should cap inbox file at maxEntries by dropping oldest entries", async () => {
|
|
105
|
+
const capped = new PushNotifier(
|
|
106
|
+
{ inboxDir: capDir, maxEntriesPerInbox: 3 },
|
|
107
|
+
capStorage,
|
|
108
|
+
capEvents
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < 6; i++) {
|
|
112
|
+
await capRouter.routeMessage({
|
|
113
|
+
from: "alice",
|
|
114
|
+
to: "bob",
|
|
115
|
+
payload: `msg-${i}`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const raw = fs.readFileSync(capped.agentInboxPath("bob"), "utf-8");
|
|
120
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
121
|
+
expect(lines).toHaveLength(3);
|
|
122
|
+
|
|
123
|
+
const entries = lines.map((l) => JSON.parse(l) as InboxFileEntry);
|
|
124
|
+
const texts = entries.map((e) => (e.content as { text: string }).text);
|
|
125
|
+
expect(texts).toEqual(["msg-3", "msg-4", "msg-5"]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should cap inbox file at maxBytes (soft cap, ~1.1× headroom)", async () => {
|
|
129
|
+
const capped = new PushNotifier(
|
|
130
|
+
{ inboxDir: capDir, maxBytesPerInbox: 400, maxEntriesPerInbox: 0 },
|
|
131
|
+
capStorage,
|
|
132
|
+
capEvents
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
for (let i = 0; i < 20; i++) {
|
|
136
|
+
await capRouter.routeMessage({
|
|
137
|
+
from: "alice",
|
|
138
|
+
to: "bob",
|
|
139
|
+
payload: `m${i}`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const size = fs.statSync(capped.agentInboxPath("bob")).size;
|
|
144
|
+
expect(size).toBeLessThanOrEqual(Math.ceil(400 * 1.1));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("should disable cap when both limits are 0", async () => {
|
|
148
|
+
const capped = new PushNotifier(
|
|
149
|
+
{ inboxDir: capDir, maxEntriesPerInbox: 0, maxBytesPerInbox: 0 },
|
|
150
|
+
capStorage,
|
|
151
|
+
capEvents
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
for (let i = 0; i < 50; i++) {
|
|
155
|
+
await capRouter.routeMessage({
|
|
156
|
+
from: "alice",
|
|
157
|
+
to: "bob",
|
|
158
|
+
payload: `m${i}`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const raw = fs.readFileSync(capped.agentInboxPath("bob"), "utf-8");
|
|
163
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
164
|
+
expect(lines).toHaveLength(50);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
87
168
|
describe("formatInboxMarkdown", () => {
|
|
88
169
|
it("should format text messages as markdown", () => {
|
|
89
170
|
const entries: InboxFileEntry[] = [
|