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.
Files changed (50) hide show
  1. package/bench/inbox-growth.bench.ts +224 -0
  2. package/dist/index.d.ts +12 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +26 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/jsonrpc/mail-push-types.d.ts +9 -0
  7. package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
  8. package/dist/jsonrpc/mail-push-types.js +1 -0
  9. package/dist/jsonrpc/mail-push-types.js.map +1 -1
  10. package/dist/jsonrpc/mail-server.d.ts +8 -1
  11. package/dist/jsonrpc/mail-server.d.ts.map +1 -1
  12. package/dist/jsonrpc/mail-server.js +42 -1
  13. package/dist/jsonrpc/mail-server.js.map +1 -1
  14. package/dist/push/notifier.d.ts +21 -0
  15. package/dist/push/notifier.d.ts.map +1 -1
  16. package/dist/push/notifier.js +84 -2
  17. package/dist/push/notifier.js.map +1 -1
  18. package/dist/storage/interface.d.ts +12 -0
  19. package/dist/storage/interface.d.ts.map +1 -1
  20. package/dist/storage/memory.d.ts +8 -0
  21. package/dist/storage/memory.d.ts.map +1 -1
  22. package/dist/storage/memory.js +38 -0
  23. package/dist/storage/memory.js.map +1 -1
  24. package/dist/storage/sqlite.d.ts +8 -0
  25. package/dist/storage/sqlite.d.ts.map +1 -1
  26. package/dist/storage/sqlite.js +51 -1
  27. package/dist/storage/sqlite.js.map +1 -1
  28. package/dist/traceability/traceability.d.ts.map +1 -1
  29. package/dist/traceability/traceability.js +7 -17
  30. package/dist/traceability/traceability.js.map +1 -1
  31. package/dist/types.d.ts +1 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +2 -1
  34. package/src/index.ts +38 -1
  35. package/src/jsonrpc/mail-push-types.ts +10 -0
  36. package/src/jsonrpc/mail-server.ts +48 -1
  37. package/src/push/notifier.ts +98 -2
  38. package/src/storage/interface.ts +11 -0
  39. package/src/storage/memory.ts +44 -0
  40. package/src/storage/sqlite.ts +78 -1
  41. package/src/traceability/traceability.ts +7 -16
  42. package/src/types.ts +1 -0
  43. package/test/load.test.ts +288 -0
  44. package/test/mail-presence.test.ts +149 -0
  45. package/test/mail-push.test.ts +44 -0
  46. package/test/mail-server.test.ts +25 -0
  47. package/test/push-notifier.test.ts +81 -0
  48. package/test/sqlite-storage.test.ts +106 -0
  49. package/test/storage.test.ts +92 -0
  50. 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
+ });
@@ -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
+ });
@@ -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[] = [