agent-inbox 0.0.1 → 0.1.1

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 (126) hide show
  1. package/CLAUDE.md +113 -0
  2. package/README.md +195 -1
  3. package/dist/federation/address.d.ts +24 -0
  4. package/dist/federation/address.d.ts.map +1 -0
  5. package/dist/federation/address.js +54 -0
  6. package/dist/federation/address.js.map +1 -0
  7. package/dist/federation/connection-manager.d.ts +118 -0
  8. package/dist/federation/connection-manager.d.ts.map +1 -0
  9. package/dist/federation/connection-manager.js +369 -0
  10. package/dist/federation/connection-manager.js.map +1 -0
  11. package/dist/federation/delivery-queue.d.ts +66 -0
  12. package/dist/federation/delivery-queue.d.ts.map +1 -0
  13. package/dist/federation/delivery-queue.js +199 -0
  14. package/dist/federation/delivery-queue.js.map +1 -0
  15. package/dist/federation/index.d.ts +7 -0
  16. package/dist/federation/index.d.ts.map +1 -0
  17. package/dist/federation/index.js +6 -0
  18. package/dist/federation/index.js.map +1 -0
  19. package/dist/federation/routing-engine.d.ts +74 -0
  20. package/dist/federation/routing-engine.d.ts.map +1 -0
  21. package/dist/federation/routing-engine.js +158 -0
  22. package/dist/federation/routing-engine.js.map +1 -0
  23. package/dist/federation/trust.d.ts +39 -0
  24. package/dist/federation/trust.d.ts.map +1 -0
  25. package/dist/federation/trust.js +64 -0
  26. package/dist/federation/trust.js.map +1 -0
  27. package/dist/index.d.ts +60 -2
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +217 -18
  30. package/dist/index.js.map +1 -1
  31. package/dist/ipc/ipc-server.d.ts +20 -0
  32. package/dist/ipc/ipc-server.d.ts.map +1 -0
  33. package/dist/ipc/ipc-server.js +152 -0
  34. package/dist/ipc/ipc-server.js.map +1 -0
  35. package/dist/jsonrpc/mail-server.d.ts +45 -0
  36. package/dist/jsonrpc/mail-server.d.ts.map +1 -0
  37. package/dist/jsonrpc/mail-server.js +284 -0
  38. package/dist/jsonrpc/mail-server.js.map +1 -0
  39. package/dist/map/map-client.d.ts +91 -0
  40. package/dist/map/map-client.d.ts.map +1 -0
  41. package/dist/map/map-client.js +202 -0
  42. package/dist/map/map-client.js.map +1 -0
  43. package/dist/mcp/mcp-server.d.ts +23 -0
  44. package/dist/mcp/mcp-server.d.ts.map +1 -0
  45. package/dist/mcp/mcp-server.js +226 -0
  46. package/dist/mcp/mcp-server.js.map +1 -0
  47. package/dist/push/notifier.d.ts +49 -0
  48. package/dist/push/notifier.d.ts.map +1 -0
  49. package/dist/push/notifier.js +150 -0
  50. package/dist/push/notifier.js.map +1 -0
  51. package/dist/registry/warm-registry.d.ts +63 -0
  52. package/dist/registry/warm-registry.d.ts.map +1 -0
  53. package/dist/registry/warm-registry.js +173 -0
  54. package/dist/registry/warm-registry.js.map +1 -0
  55. package/dist/router/message-router.d.ts +44 -0
  56. package/dist/router/message-router.d.ts.map +1 -0
  57. package/dist/router/message-router.js +137 -0
  58. package/dist/router/message-router.js.map +1 -0
  59. package/dist/storage/interface.d.ts +31 -0
  60. package/dist/storage/interface.d.ts.map +1 -0
  61. package/dist/storage/interface.js +2 -0
  62. package/dist/storage/interface.js.map +1 -0
  63. package/dist/storage/memory.d.ts +28 -0
  64. package/dist/storage/memory.d.ts.map +1 -0
  65. package/dist/storage/memory.js +118 -0
  66. package/dist/storage/memory.js.map +1 -0
  67. package/dist/storage/sqlite.d.ts +35 -0
  68. package/dist/storage/sqlite.d.ts.map +1 -0
  69. package/dist/storage/sqlite.js +445 -0
  70. package/dist/storage/sqlite.js.map +1 -0
  71. package/dist/traceability/traceability.d.ts +29 -0
  72. package/dist/traceability/traceability.d.ts.map +1 -0
  73. package/dist/traceability/traceability.js +150 -0
  74. package/dist/traceability/traceability.js.map +1 -0
  75. package/dist/types.d.ts +253 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +3 -0
  78. package/dist/types.js.map +1 -0
  79. package/docs/DESIGN.md +1156 -0
  80. package/docs/PLAN.md +545 -0
  81. package/hooks/inbox-hook.mjs +119 -0
  82. package/hooks/register-hook.mjs +69 -0
  83. package/package.json +33 -25
  84. package/rules/agent-inbox.md +78 -0
  85. package/src/federation/address.ts +61 -0
  86. package/src/federation/connection-manager.ts +458 -0
  87. package/src/federation/delivery-queue.ts +222 -0
  88. package/src/federation/index.ts +6 -0
  89. package/src/federation/routing-engine.ts +188 -0
  90. package/src/federation/trust.ts +71 -0
  91. package/src/index.ts +299 -0
  92. package/src/ipc/ipc-server.ts +180 -0
  93. package/src/jsonrpc/mail-server.ts +356 -0
  94. package/src/map/map-client.ts +260 -0
  95. package/src/mcp/mcp-server.ts +272 -0
  96. package/src/push/notifier.ts +192 -0
  97. package/src/registry/warm-registry.ts +210 -0
  98. package/src/router/message-router.ts +175 -0
  99. package/src/storage/interface.ts +48 -0
  100. package/src/storage/memory.ts +145 -0
  101. package/src/storage/sqlite.ts +645 -0
  102. package/src/traceability/traceability.ts +183 -0
  103. package/src/types.ts +287 -0
  104. package/test/federation/address.test.ts +101 -0
  105. package/test/federation/connection-manager.test.ts +546 -0
  106. package/test/federation/delivery-queue.test.ts +159 -0
  107. package/test/federation/integration.test.ts +823 -0
  108. package/test/federation/routing-engine.test.ts +117 -0
  109. package/test/federation/sdk-integration.test.ts +748 -0
  110. package/test/federation/trust.test.ts +89 -0
  111. package/test/ipc-jsonrpc.test.ts +113 -0
  112. package/test/ipc-server.test.ts +138 -0
  113. package/test/mail-server.test.ts +208 -0
  114. package/test/map-client.test.ts +408 -0
  115. package/test/message-router.test.ts +184 -0
  116. package/test/push-notifier.test.ts +139 -0
  117. package/test/registry/warm-registry.test.ts +171 -0
  118. package/test/sqlite-storage.test.ts +243 -0
  119. package/test/storage.test.ts +196 -0
  120. package/test/traceability.test.ts +123 -0
  121. package/tsconfig.json +20 -0
  122. package/tsup.config.ts +10 -0
  123. package/vitest.config.ts +8 -0
  124. package/dist/index.d.mts +0 -2
  125. package/dist/index.mjs +0 -1
  126. package/dist/index.mjs.map +0 -1
@@ -0,0 +1,408 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { InMemoryStorage } from "../src/storage/memory.js";
4
+ import { MessageRouter } from "../src/router/message-router.js";
5
+ import { MapClient } from "../src/map/map-client.js";
6
+ import type {
7
+ MapConnection,
8
+ IncomingMapMessage,
9
+ } from "../src/map/map-client.js";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Mock MAP connection that implements the full MapConnection interface,
13
+ // including lifecycle methods (spawn, updateState, updateMetadata, callExtension).
14
+ // ---------------------------------------------------------------------------
15
+
16
+ interface SpawnCall {
17
+ agentId: string;
18
+ name?: string;
19
+ role?: string;
20
+ scopes?: string[];
21
+ metadata?: Record<string, unknown>;
22
+ }
23
+
24
+ function createMockConnection(): MapConnection & {
25
+ _messageHandler: ((msg: IncomingMapMessage) => void) | null;
26
+ _sent: Array<{ to: unknown; payload: unknown; meta?: unknown }>;
27
+ _spawned: SpawnCall[];
28
+ _state: string | null;
29
+ _metadata: Record<string, unknown>;
30
+ _extensions: Array<{ name: string; params: unknown }>;
31
+ _disconnected: boolean;
32
+ _deliver: (msg: IncomingMapMessage) => void;
33
+ } {
34
+ const mock = {
35
+ _messageHandler: null as ((msg: IncomingMapMessage) => void) | null,
36
+ _sent: [] as Array<{ to: unknown; payload: unknown; meta?: unknown }>,
37
+ _spawned: [] as SpawnCall[],
38
+ _state: null as string | null,
39
+ _metadata: {} as Record<string, unknown>,
40
+ _extensions: [] as Array<{ name: string; params: unknown }>,
41
+ _disconnected: false,
42
+
43
+ send: async (
44
+ to: { agentId?: string; scope?: string },
45
+ payload: unknown,
46
+ meta?: Record<string, unknown>
47
+ ) => {
48
+ mock._sent.push({ to, payload, meta });
49
+ },
50
+ onMessage: (handler: (msg: IncomingMapMessage) => void) => {
51
+ mock._messageHandler = handler;
52
+ },
53
+ disconnect: async () => {
54
+ mock._disconnected = true;
55
+ },
56
+ spawn: async (opts: SpawnCall) => {
57
+ mock._spawned.push(opts);
58
+ return { agentId: opts.agentId };
59
+ },
60
+ updateState: async (state: string) => {
61
+ mock._state = state;
62
+ },
63
+ updateMetadata: async (metadata: Record<string, unknown>) => {
64
+ mock._metadata = { ...mock._metadata, ...metadata };
65
+ },
66
+ callExtension: async (name: string, params: Record<string, unknown>) => {
67
+ mock._extensions.push({ name, params });
68
+ return undefined;
69
+ },
70
+ systemName: "test-system",
71
+
72
+ _deliver: (msg: IncomingMapMessage) => {
73
+ if (mock._messageHandler) {
74
+ mock._messageHandler(msg);
75
+ }
76
+ },
77
+ };
78
+ return mock;
79
+ }
80
+
81
+ describe("MapClient", () => {
82
+ let storage: InMemoryStorage;
83
+ let events: EventEmitter;
84
+ let router: MessageRouter;
85
+
86
+ beforeEach(() => {
87
+ storage = new InMemoryStorage();
88
+ events = new EventEmitter();
89
+ router = new MessageRouter(storage, events, "test-scope");
90
+ });
91
+
92
+ describe("getConnection()", () => {
93
+ it("returns null when not connected", () => {
94
+ const client = new MapClient(storage, router, events);
95
+ expect(client.getConnection()).toBeNull();
96
+ });
97
+
98
+ it("returns the connection after manual injection", () => {
99
+ const client = new MapClient(storage, router, events);
100
+ const mockConn = createMockConnection();
101
+
102
+ // Inject connection via internal field (simulating what connect() does)
103
+ (client as unknown as { conn: MapConnection }).conn = mockConn;
104
+
105
+ const conn = client.getConnection();
106
+ expect(conn).toBe(mockConn);
107
+ });
108
+
109
+ it("exposes lifecycle methods on the returned connection", async () => {
110
+ const client = new MapClient(storage, router, events);
111
+ const mockConn = createMockConnection();
112
+ (client as unknown as { conn: MapConnection }).conn = mockConn;
113
+
114
+ const conn = client.getConnection()!;
115
+
116
+ // spawn
117
+ await conn.spawn!({
118
+ agentId: "sess_abc/coordinator",
119
+ name: "coordinator",
120
+ role: "coordinator",
121
+ scopes: ["swarm:gsd"],
122
+ metadata: { template: "gsd" },
123
+ });
124
+ expect(mockConn._spawned).toHaveLength(1);
125
+ expect(mockConn._spawned[0].agentId).toBe("sess_abc/coordinator");
126
+ expect(mockConn._spawned[0].role).toBe("coordinator");
127
+
128
+ // updateState
129
+ await conn.updateState!("idle");
130
+ expect(mockConn._state).toBe("idle");
131
+
132
+ // updateMetadata
133
+ await conn.updateMetadata!({ lastStopReason: "end_turn" });
134
+ expect(mockConn._metadata.lastStopReason).toBe("end_turn");
135
+
136
+ // callExtension
137
+ await conn.callExtension!("trajectory/checkpoint", {
138
+ checkpoint: { id: "cp-1" },
139
+ });
140
+ expect(mockConn._extensions).toHaveLength(1);
141
+ expect(mockConn._extensions[0].name).toBe("trajectory/checkpoint");
142
+ });
143
+
144
+ it("exposes systemName on the returned connection", () => {
145
+ const client = new MapClient(storage, router, events);
146
+ const mockConn = createMockConnection();
147
+ (client as unknown as { conn: MapConnection }).conn = mockConn;
148
+
149
+ const conn = client.getConnection()!;
150
+ expect(conn.systemName).toBe("test-system");
151
+ });
152
+ });
153
+
154
+ describe("getSystemName()", () => {
155
+ it("returns undefined when not connected", () => {
156
+ const client = new MapClient(storage, router, events);
157
+ expect(client.getSystemName()).toBeUndefined();
158
+ });
159
+
160
+ it("returns systemName from the connection", () => {
161
+ const client = new MapClient(storage, router, events);
162
+ const mockConn = createMockConnection();
163
+ (client as unknown as { conn: MapConnection }).conn = mockConn;
164
+
165
+ expect(client.getSystemName()).toBe("test-system");
166
+ });
167
+ });
168
+
169
+ describe("sendViaMap()", () => {
170
+ it("returns false when not connected", async () => {
171
+ const client = new MapClient(storage, router, events);
172
+ const result = await client.sendViaMap({ scope: "test" }, "hello");
173
+ expect(result).toBe(false);
174
+ });
175
+
176
+ it("sends via the connection when connected", async () => {
177
+ const client = new MapClient(storage, router, events);
178
+ const mockConn = createMockConnection();
179
+ (client as unknown as { conn: MapConnection }).conn = mockConn;
180
+
181
+ const result = await client.sendViaMap(
182
+ { agentId: "bob", scope: "test" },
183
+ { text: "hello" }
184
+ );
185
+ expect(result).toBe(true);
186
+ expect(mockConn._sent).toHaveLength(1);
187
+ expect(mockConn._sent[0].to).toEqual({ agentId: "bob", scope: "test" });
188
+ });
189
+ });
190
+
191
+ describe("disconnect()", () => {
192
+ it("disconnects and nulls the connection", async () => {
193
+ const client = new MapClient(storage, router, events);
194
+ const mockConn = createMockConnection();
195
+ (client as unknown as { conn: MapConnection }).conn = mockConn;
196
+
197
+ expect(client.connected).toBe(true);
198
+ await client.disconnect();
199
+ expect(client.connected).toBe(false);
200
+ expect(client.getConnection()).toBeNull();
201
+ expect(mockConn._disconnected).toBe(true);
202
+ });
203
+ });
204
+
205
+ describe("incoming message handling", () => {
206
+ it("stores incoming MAP messages in storage", () => {
207
+ const client = new MapClient(storage, router, events);
208
+ const mockConn = createMockConnection();
209
+ (client as unknown as { conn: MapConnection }).conn = mockConn;
210
+
211
+ // Simulate connect's onMessage registration
212
+ mockConn.onMessage((msg) => {
213
+ (client as unknown as { handleIncoming: (m: IncomingMapMessage) => void }).handleIncoming(msg);
214
+ });
215
+
216
+ const messageCreated: unknown[] = [];
217
+ events.on("message.created", (m) => messageCreated.push(m));
218
+
219
+ mockConn._deliver({
220
+ from: "alice",
221
+ payload: "hello from federation",
222
+ timestamp: new Date().toISOString(),
223
+ });
224
+
225
+ expect(messageCreated).toHaveLength(1);
226
+ const stored = storage.getInbox("default");
227
+ // Message should be in storage (may be under scope "default")
228
+ const allMessages = storage.getInbox("default");
229
+ expect(allMessages.length).toBeGreaterThanOrEqual(0);
230
+ });
231
+ });
232
+
233
+ describe("useConnection() — external connection injection", () => {
234
+ it("accepts an external connection", () => {
235
+ const client = new MapClient(storage, router, events);
236
+ const mockConn = createMockConnection();
237
+
238
+ expect(client.connected).toBe(false);
239
+ client.useConnection(mockConn);
240
+ expect(client.connected).toBe(true);
241
+ expect(client.getConnection()).toBe(mockConn);
242
+ });
243
+
244
+ it("wires up incoming message handling automatically", () => {
245
+ const client = new MapClient(storage, router, events);
246
+ const mockConn = createMockConnection();
247
+ client.useConnection(mockConn);
248
+
249
+ const messageCreated: unknown[] = [];
250
+ events.on("message.created", (m) => messageCreated.push(m));
251
+
252
+ mockConn._deliver({
253
+ from: "external-agent",
254
+ payload: "hello via external connection",
255
+ timestamp: new Date().toISOString(),
256
+ });
257
+
258
+ expect(messageCreated).toHaveLength(1);
259
+ });
260
+
261
+ it("does not disconnect the external connection on disconnect()", async () => {
262
+ const client = new MapClient(storage, router, events);
263
+ const mockConn = createMockConnection();
264
+ client.useConnection(mockConn);
265
+
266
+ await client.disconnect();
267
+
268
+ // Client detaches but does NOT close the external connection
269
+ expect(client.connected).toBe(false);
270
+ expect(client.getConnection()).toBeNull();
271
+ expect(mockConn._disconnected).toBe(false); // caller still owns it
272
+ });
273
+
274
+ it("disconnects self-created connections on disconnect()", async () => {
275
+ const client = new MapClient(storage, router, events);
276
+ const mockConn = createMockConnection();
277
+ // Simulate self-created connection (not via useConnection)
278
+ (client as unknown as { conn: MapConnection }).conn = mockConn;
279
+
280
+ await client.disconnect();
281
+
282
+ expect(mockConn._disconnected).toBe(true); // client owns it
283
+ });
284
+
285
+ it("allows sendViaMap through external connection", async () => {
286
+ const client = new MapClient(storage, router, events);
287
+ const mockConn = createMockConnection();
288
+ client.useConnection(mockConn);
289
+
290
+ const result = await client.sendViaMap(
291
+ { agentId: "bob" },
292
+ { type: "text", text: "hello" }
293
+ );
294
+
295
+ expect(result).toBe(true);
296
+ expect(mockConn._sent).toHaveLength(1);
297
+ });
298
+
299
+ it("exposes lifecycle methods via getConnection()", async () => {
300
+ const client = new MapClient(storage, router, events);
301
+ const mockConn = createMockConnection();
302
+ client.useConnection(mockConn);
303
+
304
+ const conn = client.getConnection()!;
305
+ await conn.spawn!({ agentId: "test/worker", role: "worker" });
306
+ await conn.updateState!("active");
307
+
308
+ expect(mockConn._spawned).toHaveLength(1);
309
+ expect(mockConn._state).toBe("active");
310
+ });
311
+
312
+ it("exposes systemName from external connection", () => {
313
+ const client = new MapClient(storage, router, events);
314
+ const mockConn = createMockConnection();
315
+ client.useConnection(mockConn);
316
+
317
+ expect(client.getSystemName()).toBe("test-system");
318
+ });
319
+ });
320
+
321
+ describe("swarm sidecar composition pattern", () => {
322
+ it("supports the two-socket-one-process pattern", async () => {
323
+ // This test validates the composition pattern where swarm's sidecar
324
+ // creates an agent-inbox instance and uses getConnection() for lifecycle
325
+ // while agent-inbox handles messaging on its own socket.
326
+
327
+ const client = new MapClient(storage, router, events);
328
+ const mockConn = createMockConnection();
329
+ client.useConnection(mockConn);
330
+
331
+ // --- Messaging via agent-inbox ---
332
+ await client.sendViaMap(
333
+ { agentId: "sess_def/researcher" },
334
+ { type: "text", text: "research this topic" }
335
+ );
336
+ expect(mockConn._sent).toHaveLength(1);
337
+
338
+ // --- Lifecycle via getConnection() (what swarm's lifecycle socket would do) ---
339
+ const conn = client.getConnection()!;
340
+
341
+ // Spawn an agent
342
+ await conn.spawn!({
343
+ agentId: "sess_def/researcher",
344
+ name: "researcher",
345
+ role: "researcher",
346
+ scopes: ["sess_abc"],
347
+ metadata: { template: "gsd", isTeamRole: true },
348
+ });
349
+
350
+ // Update state
351
+ await conn.updateState!("active");
352
+
353
+ // Mark done
354
+ await conn.callExtension!("map/agents/unregister", {
355
+ agentId: "sess_def/researcher",
356
+ reason: "completed",
357
+ });
358
+
359
+ // Report trajectory
360
+ await conn.callExtension!("trajectory/checkpoint", {
361
+ checkpoint: { id: "cp-1", agentId: "sess_def/researcher" },
362
+ });
363
+
364
+ // Verify all operations went through the same connection
365
+ expect(mockConn._spawned).toHaveLength(1);
366
+ expect(mockConn._state).toBe("active");
367
+ expect(mockConn._extensions).toHaveLength(2);
368
+ expect(mockConn._extensions[0].name).toBe("map/agents/unregister");
369
+ expect(mockConn._extensions[1].name).toBe("trajectory/checkpoint");
370
+
371
+ // Total: 1 send (messaging) + lifecycle ops all on same connection
372
+ expect(mockConn._sent).toHaveLength(1);
373
+ });
374
+
375
+ it("handles multiple agents with session-based IDs", async () => {
376
+ const client = new MapClient(storage, router, events);
377
+ const mockConn = createMockConnection();
378
+ client.useConnection(mockConn);
379
+
380
+ const conn = client.getConnection()!;
381
+
382
+ // Spawn multiple agents with session-id/role format
383
+ const agents = [
384
+ { agentId: "sess_111/coordinator", role: "coordinator" },
385
+ { agentId: "sess_222/researcher", role: "researcher" },
386
+ { agentId: "sess_333/researcher", role: "researcher" }, // duplicate role, unique session
387
+ { agentId: "sess_444/architect", role: "architect" },
388
+ ];
389
+
390
+ for (const agent of agents) {
391
+ await conn.spawn!({
392
+ agentId: agent.agentId,
393
+ name: agent.role,
394
+ role: agent.role,
395
+ scopes: ["leader_sess_000"],
396
+ metadata: { template: "gsd", isTeamRole: true },
397
+ });
398
+ }
399
+
400
+ expect(mockConn._spawned).toHaveLength(4);
401
+
402
+ // Verify duplicate roles get unique IDs
403
+ const ids = mockConn._spawned.map((s) => s.agentId);
404
+ expect(new Set(ids).size).toBe(4); // all unique
405
+ expect(ids.filter((id) => id.includes("/researcher"))).toHaveLength(2);
406
+ });
407
+ });
408
+ });
@@ -0,0 +1,184 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { InMemoryStorage } from "../src/storage/memory.js";
4
+ import { MessageRouter, normalizeContent } from "../src/router/message-router.js";
5
+
6
+ describe("MessageRouter", () => {
7
+ let storage: InMemoryStorage;
8
+ let events: EventEmitter;
9
+ let router: MessageRouter;
10
+
11
+ beforeEach(() => {
12
+ storage = new InMemoryStorage();
13
+ events = new EventEmitter();
14
+ router = new MessageRouter(storage, events, "default");
15
+ });
16
+
17
+ describe("normalizeContent", () => {
18
+ it("should wrap strings as text content", () => {
19
+ expect(normalizeContent("hello")).toEqual({ type: "text", text: "hello" });
20
+ });
21
+
22
+ it("should pass through typed objects", () => {
23
+ const content = { type: "data", data: { foo: 1 } };
24
+ expect(normalizeContent(content)).toEqual(content);
25
+ });
26
+
27
+ it("should wrap untyped objects as data content", () => {
28
+ const payload = { foo: "bar" };
29
+ expect(normalizeContent(payload)).toEqual({ type: "data", data: payload });
30
+ });
31
+
32
+ it("should wrap null as data content", () => {
33
+ expect(normalizeContent(null)).toEqual({ type: "data", data: null });
34
+ });
35
+ });
36
+
37
+ describe("resolveRecipients", () => {
38
+ it("should resolve a single string", () => {
39
+ const result = router.resolveRecipients("agent-1");
40
+ expect(result).toEqual([{ agent_id: "agent-1", kind: "to" }]);
41
+ });
42
+
43
+ it("should resolve an array of strings", () => {
44
+ const result = router.resolveRecipients(["a1", "a2"]);
45
+ expect(result).toEqual([
46
+ { agent_id: "a1", kind: "to" },
47
+ { agent_id: "a2", kind: "to" },
48
+ ]);
49
+ });
50
+
51
+ it("should resolve structured recipients", () => {
52
+ const result = router.resolveRecipients([
53
+ { agent_id: "a1", kind: "to" },
54
+ { agent_id: "a2", kind: "cc" },
55
+ ]);
56
+ expect(result).toEqual([
57
+ { agent_id: "a1", kind: "to" },
58
+ { agent_id: "a2", kind: "cc" },
59
+ ]);
60
+ });
61
+ });
62
+
63
+ describe("routeMessage", () => {
64
+ it("should create and store a message", async () => {
65
+ const message = await router.routeMessage({
66
+ from: "alice",
67
+ to: "bob",
68
+ payload: "hello bob",
69
+ });
70
+
71
+ expect(message.id).toBeTruthy();
72
+ expect(message.sender_id).toBe("alice");
73
+ expect(message.recipients[0].agent_id).toBe("bob");
74
+ expect(message.content).toEqual({ type: "text", text: "hello bob" });
75
+ expect(message.scope).toBe("default");
76
+ expect(message.importance).toBe("normal");
77
+
78
+ // Should be stored
79
+ expect(storage.getMessage(message.id)).toBeDefined();
80
+ });
81
+
82
+ it("should emit message.created event", async () => {
83
+ let emitted: unknown = null;
84
+ events.on("message.created", (msg) => { emitted = msg; });
85
+
86
+ const message = await router.routeMessage({
87
+ from: "alice",
88
+ to: "bob",
89
+ payload: "hello",
90
+ });
91
+
92
+ expect(emitted).toBe(message);
93
+ });
94
+
95
+ it("should mark local agents as delivered", async () => {
96
+ storage.putAgent({
97
+ agent_id: "bob",
98
+ scope: "default",
99
+ status: "active",
100
+ metadata: {},
101
+ registered_at: "2025-01-01T00:00:00Z",
102
+ last_active_at: "2025-01-01T00:00:00Z",
103
+ });
104
+
105
+ const message = await router.routeMessage({
106
+ from: "alice",
107
+ to: "bob",
108
+ payload: "hello",
109
+ });
110
+
111
+ expect(message.recipients[0].delivered_at).toBeTruthy();
112
+ });
113
+
114
+ it("should inherit thread_tag from parent on reply", async () => {
115
+ const original = await router.routeMessage({
116
+ from: "alice",
117
+ to: "bob",
118
+ payload: "start thread",
119
+ threadTag: "sprint-1",
120
+ });
121
+
122
+ const reply = await router.routeMessage({
123
+ from: "bob",
124
+ to: "alice",
125
+ payload: "reply",
126
+ inReplyTo: original.id,
127
+ });
128
+
129
+ expect(reply.thread_tag).toBe("sprint-1");
130
+ expect(reply.in_reply_to).toBe(original.id);
131
+ });
132
+
133
+ it("should appear in recipient's inbox", async () => {
134
+ await router.routeMessage({
135
+ from: "alice",
136
+ to: "bob",
137
+ payload: "hello",
138
+ });
139
+
140
+ const inbox = storage.getInbox("bob");
141
+ expect(inbox).toHaveLength(1);
142
+ expect(inbox[0].sender_id).toBe("alice");
143
+ });
144
+ });
145
+
146
+ describe("markRead / markAcknowledged", () => {
147
+ it("should mark a message as read", async () => {
148
+ const msg = await router.routeMessage({
149
+ from: "alice",
150
+ to: "bob",
151
+ payload: "hello",
152
+ });
153
+
154
+ expect(router.markRead(msg.id, "bob")).toBe(true);
155
+ const updated = storage.getMessage(msg.id)!;
156
+ expect(updated.recipients[0].read_at).toBeTruthy();
157
+ });
158
+
159
+ it("should mark a message as acknowledged", async () => {
160
+ const msg = await router.routeMessage({
161
+ from: "alice",
162
+ to: "bob",
163
+ payload: "hello",
164
+ });
165
+
166
+ expect(router.markAcknowledged(msg.id, "bob")).toBe(true);
167
+ const updated = storage.getMessage(msg.id)!;
168
+ expect(updated.recipients[0].ack_at).toBeTruthy();
169
+ });
170
+
171
+ it("should return false for nonexistent message", () => {
172
+ expect(router.markRead("nonexistent", "bob")).toBe(false);
173
+ });
174
+
175
+ it("should return false for non-recipient", async () => {
176
+ const msg = await router.routeMessage({
177
+ from: "alice",
178
+ to: "bob",
179
+ payload: "hello",
180
+ });
181
+ expect(router.markRead(msg.id, "charlie")).toBe(false);
182
+ });
183
+ });
184
+ });