swarm-mail 0.1.0

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 (36) hide show
  1. package/README.md +201 -0
  2. package/package.json +28 -0
  3. package/src/adapter.ts +306 -0
  4. package/src/index.ts +57 -0
  5. package/src/pglite.ts +189 -0
  6. package/src/streams/agent-mail.test.ts +777 -0
  7. package/src/streams/agent-mail.ts +535 -0
  8. package/src/streams/debug.test.ts +500 -0
  9. package/src/streams/debug.ts +727 -0
  10. package/src/streams/effect/ask.integration.test.ts +314 -0
  11. package/src/streams/effect/ask.ts +202 -0
  12. package/src/streams/effect/cursor.integration.test.ts +418 -0
  13. package/src/streams/effect/cursor.ts +288 -0
  14. package/src/streams/effect/deferred.test.ts +357 -0
  15. package/src/streams/effect/deferred.ts +445 -0
  16. package/src/streams/effect/index.ts +17 -0
  17. package/src/streams/effect/layers.ts +73 -0
  18. package/src/streams/effect/lock.test.ts +385 -0
  19. package/src/streams/effect/lock.ts +399 -0
  20. package/src/streams/effect/mailbox.test.ts +260 -0
  21. package/src/streams/effect/mailbox.ts +318 -0
  22. package/src/streams/events.test.ts +924 -0
  23. package/src/streams/events.ts +329 -0
  24. package/src/streams/index.test.ts +229 -0
  25. package/src/streams/index.ts +578 -0
  26. package/src/streams/migrations.test.ts +359 -0
  27. package/src/streams/migrations.ts +362 -0
  28. package/src/streams/projections.test.ts +611 -0
  29. package/src/streams/projections.ts +564 -0
  30. package/src/streams/store.integration.test.ts +658 -0
  31. package/src/streams/store.ts +1129 -0
  32. package/src/streams/swarm-mail.ts +552 -0
  33. package/src/types/adapter.ts +392 -0
  34. package/src/types/database.ts +127 -0
  35. package/src/types/index.ts +26 -0
  36. package/tsconfig.json +22 -0
@@ -0,0 +1,777 @@
1
+ /**
2
+ * Unit tests for Agent Mail Tools (TDD - RED phase)
3
+ *
4
+ * These tools provide the same API as the MCP-based agent-mail.ts
5
+ * but use the embedded PGLite event store instead.
6
+ *
7
+ * Key constraints (must match existing API):
8
+ * - agentmail_init: Register agent, return name and project key
9
+ * - agentmail_send: Send message to agents
10
+ * - agentmail_inbox: Fetch inbox (limit 5, no bodies by default)
11
+ * - agentmail_read_message: Get single message with body
12
+ * - agentmail_reserve: Reserve files, detect conflicts
13
+ * - agentmail_release: Release reservations
14
+ * - agentmail_ack: Acknowledge message
15
+ * - agentmail_health: Check if store is healthy
16
+ */
17
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
18
+ import { mkdir, rm } from "node:fs/promises";
19
+ import { join } from "node:path";
20
+ import { tmpdir } from "node:os";
21
+ import { closeDatabase } from "./index";
22
+ import {
23
+ initAgent,
24
+ sendAgentMessage,
25
+ getAgentInbox,
26
+ readAgentMessage,
27
+ reserveAgentFiles,
28
+ releaseAgentFiles,
29
+ acknowledgeMessage,
30
+ checkHealth,
31
+ type AgentMailContext,
32
+ } from "./agent-mail";
33
+
34
+ let TEST_PROJECT_PATH: string;
35
+
36
+ describe("Agent Mail Tools", () => {
37
+ beforeEach(async () => {
38
+ TEST_PROJECT_PATH = join(
39
+ tmpdir(),
40
+ `agent-mail-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
41
+ );
42
+ await mkdir(TEST_PROJECT_PATH, { recursive: true });
43
+ });
44
+
45
+ afterEach(async () => {
46
+ await closeDatabase(TEST_PROJECT_PATH);
47
+ try {
48
+ await rm(join(TEST_PROJECT_PATH, ".opencode"), { recursive: true });
49
+ } catch {
50
+ // Ignore
51
+ }
52
+ });
53
+
54
+ // ==========================================================================
55
+ // initAgent (agentmail_init)
56
+ // ==========================================================================
57
+
58
+ describe("initAgent", () => {
59
+ it("registers agent and returns context", async () => {
60
+ const ctx = await initAgent({
61
+ projectPath: TEST_PROJECT_PATH,
62
+ taskDescription: "Testing agent mail",
63
+ });
64
+
65
+ expect(ctx.projectKey).toBe(TEST_PROJECT_PATH);
66
+ expect(ctx.agentName).toBeTruthy();
67
+ expect(ctx.agentName).toMatch(/^[A-Z][a-z]+[A-Z][a-z]+$/); // AdjectiveNoun format
68
+ });
69
+
70
+ it("uses provided agent name", async () => {
71
+ const ctx = await initAgent({
72
+ projectPath: TEST_PROJECT_PATH,
73
+ agentName: "BlueLake",
74
+ });
75
+
76
+ expect(ctx.agentName).toBe("BlueLake");
77
+ });
78
+
79
+ it("generates unique names for multiple agents", async () => {
80
+ const ctx1 = await initAgent({ projectPath: TEST_PROJECT_PATH });
81
+ const ctx2 = await initAgent({ projectPath: TEST_PROJECT_PATH });
82
+
83
+ // Both should have names, but they might be the same if re-registering
84
+ expect(ctx1.agentName).toBeTruthy();
85
+ expect(ctx2.agentName).toBeTruthy();
86
+ });
87
+
88
+ it("includes program and model in registration", async () => {
89
+ const ctx = await initAgent({
90
+ projectPath: TEST_PROJECT_PATH,
91
+ program: "opencode",
92
+ model: "claude-sonnet-4",
93
+ });
94
+
95
+ expect(ctx.agentName).toBeTruthy();
96
+ // The context should be usable for subsequent operations
97
+ });
98
+ });
99
+
100
+ // ==========================================================================
101
+ // sendAgentMessage (agentmail_send)
102
+ // ==========================================================================
103
+
104
+ describe("sendAgentMessage", () => {
105
+ it("sends message to recipients", async () => {
106
+ const sender = await initAgent({
107
+ projectPath: TEST_PROJECT_PATH,
108
+ agentName: "Sender",
109
+ });
110
+ const receiver = await initAgent({
111
+ projectPath: TEST_PROJECT_PATH,
112
+ agentName: "Receiver",
113
+ });
114
+
115
+ const result = await sendAgentMessage({
116
+ projectPath: TEST_PROJECT_PATH,
117
+ fromAgent: sender.agentName,
118
+ toAgents: [receiver.agentName],
119
+ subject: "Hello",
120
+ body: "World",
121
+ });
122
+
123
+ expect(result.success).toBe(true);
124
+ expect(result.messageId).toBeGreaterThan(0);
125
+ });
126
+
127
+ it("supports thread_id for grouping", async () => {
128
+ const sender = await initAgent({
129
+ projectPath: TEST_PROJECT_PATH,
130
+ agentName: "Sender",
131
+ });
132
+ const receiver = await initAgent({
133
+ projectPath: TEST_PROJECT_PATH,
134
+ agentName: "Receiver",
135
+ });
136
+
137
+ const result = await sendAgentMessage({
138
+ projectPath: TEST_PROJECT_PATH,
139
+ fromAgent: sender.agentName,
140
+ toAgents: [receiver.agentName],
141
+ subject: "Task update",
142
+ body: "Progress report",
143
+ threadId: "bd-123",
144
+ });
145
+
146
+ expect(result.success).toBe(true);
147
+ expect(result.threadId).toBe("bd-123");
148
+ });
149
+
150
+ it("supports importance levels", async () => {
151
+ const sender = await initAgent({
152
+ projectPath: TEST_PROJECT_PATH,
153
+ agentName: "Sender",
154
+ });
155
+ const receiver = await initAgent({
156
+ projectPath: TEST_PROJECT_PATH,
157
+ agentName: "Receiver",
158
+ });
159
+
160
+ const result = await sendAgentMessage({
161
+ projectPath: TEST_PROJECT_PATH,
162
+ fromAgent: sender.agentName,
163
+ toAgents: [receiver.agentName],
164
+ subject: "Urgent",
165
+ body: "Please respond",
166
+ importance: "urgent",
167
+ ackRequired: true,
168
+ });
169
+
170
+ expect(result.success).toBe(true);
171
+ });
172
+
173
+ it("sends to multiple recipients", async () => {
174
+ const sender = await initAgent({
175
+ projectPath: TEST_PROJECT_PATH,
176
+ agentName: "Sender",
177
+ });
178
+ await initAgent({
179
+ projectPath: TEST_PROJECT_PATH,
180
+ agentName: "Receiver1",
181
+ });
182
+ await initAgent({
183
+ projectPath: TEST_PROJECT_PATH,
184
+ agentName: "Receiver2",
185
+ });
186
+
187
+ const result = await sendAgentMessage({
188
+ projectPath: TEST_PROJECT_PATH,
189
+ fromAgent: sender.agentName,
190
+ toAgents: ["Receiver1", "Receiver2"],
191
+ subject: "Broadcast",
192
+ body: "Hello everyone",
193
+ });
194
+
195
+ expect(result.success).toBe(true);
196
+ expect(result.recipientCount).toBe(2);
197
+ });
198
+ });
199
+
200
+ // ==========================================================================
201
+ // getAgentInbox (agentmail_inbox)
202
+ // ==========================================================================
203
+
204
+ describe("getAgentInbox", () => {
205
+ it("returns empty inbox for new agent", async () => {
206
+ const agent = await initAgent({
207
+ projectPath: TEST_PROJECT_PATH,
208
+ agentName: "NewAgent",
209
+ });
210
+
211
+ const inbox = await getAgentInbox({
212
+ projectPath: TEST_PROJECT_PATH,
213
+ agentName: agent.agentName,
214
+ });
215
+
216
+ expect(inbox.messages).toEqual([]);
217
+ expect(inbox.total).toBe(0);
218
+ });
219
+
220
+ it("returns messages sent to agent", async () => {
221
+ const sender = await initAgent({
222
+ projectPath: TEST_PROJECT_PATH,
223
+ agentName: "Sender",
224
+ });
225
+ const receiver = await initAgent({
226
+ projectPath: TEST_PROJECT_PATH,
227
+ agentName: "Receiver",
228
+ });
229
+
230
+ await sendAgentMessage({
231
+ projectPath: TEST_PROJECT_PATH,
232
+ fromAgent: sender.agentName,
233
+ toAgents: [receiver.agentName],
234
+ subject: "Test message",
235
+ body: "Body content",
236
+ });
237
+
238
+ const inbox = await getAgentInbox({
239
+ projectPath: TEST_PROJECT_PATH,
240
+ agentName: receiver.agentName,
241
+ });
242
+
243
+ expect(inbox.messages.length).toBe(1);
244
+ expect(inbox.messages[0]?.subject).toBe("Test message");
245
+ expect(inbox.messages[0]?.from_agent).toBe("Sender");
246
+ });
247
+
248
+ it("excludes body by default (context-safe)", async () => {
249
+ const sender = await initAgent({
250
+ projectPath: TEST_PROJECT_PATH,
251
+ agentName: "Sender",
252
+ });
253
+ const receiver = await initAgent({
254
+ projectPath: TEST_PROJECT_PATH,
255
+ agentName: "Receiver",
256
+ });
257
+
258
+ await sendAgentMessage({
259
+ projectPath: TEST_PROJECT_PATH,
260
+ fromAgent: sender.agentName,
261
+ toAgents: [receiver.agentName],
262
+ subject: "Test",
263
+ body: "This body should NOT be included",
264
+ });
265
+
266
+ const inbox = await getAgentInbox({
267
+ projectPath: TEST_PROJECT_PATH,
268
+ agentName: receiver.agentName,
269
+ includeBodies: false, // Default
270
+ });
271
+
272
+ expect(inbox.messages[0]?.body).toBeUndefined();
273
+ });
274
+
275
+ it("enforces max limit of 5", async () => {
276
+ const sender = await initAgent({
277
+ projectPath: TEST_PROJECT_PATH,
278
+ agentName: "Sender",
279
+ });
280
+ const receiver = await initAgent({
281
+ projectPath: TEST_PROJECT_PATH,
282
+ agentName: "Receiver",
283
+ });
284
+
285
+ // Send 10 messages
286
+ for (let i = 0; i < 10; i++) {
287
+ await sendAgentMessage({
288
+ projectPath: TEST_PROJECT_PATH,
289
+ fromAgent: sender.agentName,
290
+ toAgents: [receiver.agentName],
291
+ subject: `Message ${i}`,
292
+ body: "Body",
293
+ });
294
+ }
295
+
296
+ const inbox = await getAgentInbox({
297
+ projectPath: TEST_PROJECT_PATH,
298
+ agentName: receiver.agentName,
299
+ limit: 100, // Request more than allowed
300
+ });
301
+
302
+ // Should be capped at 5
303
+ expect(inbox.messages.length).toBeLessThanOrEqual(5);
304
+ });
305
+
306
+ it("filters urgent messages", async () => {
307
+ const sender = await initAgent({
308
+ projectPath: TEST_PROJECT_PATH,
309
+ agentName: "Sender",
310
+ });
311
+ const receiver = await initAgent({
312
+ projectPath: TEST_PROJECT_PATH,
313
+ agentName: "Receiver",
314
+ });
315
+
316
+ await sendAgentMessage({
317
+ projectPath: TEST_PROJECT_PATH,
318
+ fromAgent: sender.agentName,
319
+ toAgents: [receiver.agentName],
320
+ subject: "Normal",
321
+ body: "Body",
322
+ importance: "normal",
323
+ });
324
+ await sendAgentMessage({
325
+ projectPath: TEST_PROJECT_PATH,
326
+ fromAgent: sender.agentName,
327
+ toAgents: [receiver.agentName],
328
+ subject: "Urgent",
329
+ body: "Body",
330
+ importance: "urgent",
331
+ });
332
+
333
+ const inbox = await getAgentInbox({
334
+ projectPath: TEST_PROJECT_PATH,
335
+ agentName: receiver.agentName,
336
+ urgentOnly: true,
337
+ });
338
+
339
+ expect(inbox.messages.length).toBe(1);
340
+ expect(inbox.messages[0]?.subject).toBe("Urgent");
341
+ });
342
+ });
343
+
344
+ // ==========================================================================
345
+ // readAgentMessage (agentmail_read_message)
346
+ // ==========================================================================
347
+
348
+ describe("readAgentMessage", () => {
349
+ it("returns full message with body", async () => {
350
+ const sender = await initAgent({
351
+ projectPath: TEST_PROJECT_PATH,
352
+ agentName: "Sender",
353
+ });
354
+ const receiver = await initAgent({
355
+ projectPath: TEST_PROJECT_PATH,
356
+ agentName: "Receiver",
357
+ });
358
+
359
+ const sent = await sendAgentMessage({
360
+ projectPath: TEST_PROJECT_PATH,
361
+ fromAgent: sender.agentName,
362
+ toAgents: [receiver.agentName],
363
+ subject: "Full message",
364
+ body: "This is the full body content",
365
+ });
366
+
367
+ const message = await readAgentMessage({
368
+ projectPath: TEST_PROJECT_PATH,
369
+ messageId: sent.messageId,
370
+ });
371
+
372
+ expect(message).not.toBeNull();
373
+ expect(message?.subject).toBe("Full message");
374
+ expect(message?.body).toBe("This is the full body content");
375
+ });
376
+
377
+ it("marks message as read", async () => {
378
+ const sender = await initAgent({
379
+ projectPath: TEST_PROJECT_PATH,
380
+ agentName: "Sender",
381
+ });
382
+ const receiver = await initAgent({
383
+ projectPath: TEST_PROJECT_PATH,
384
+ agentName: "Receiver",
385
+ });
386
+
387
+ const sent = await sendAgentMessage({
388
+ projectPath: TEST_PROJECT_PATH,
389
+ fromAgent: sender.agentName,
390
+ toAgents: [receiver.agentName],
391
+ subject: "To be read",
392
+ body: "Body",
393
+ });
394
+
395
+ // Read the message
396
+ await readAgentMessage({
397
+ projectPath: TEST_PROJECT_PATH,
398
+ messageId: sent.messageId,
399
+ agentName: receiver.agentName,
400
+ markAsRead: true,
401
+ });
402
+
403
+ // Check inbox - should show as read (or filtered out if unreadOnly)
404
+ const inbox = await getAgentInbox({
405
+ projectPath: TEST_PROJECT_PATH,
406
+ agentName: receiver.agentName,
407
+ unreadOnly: true,
408
+ });
409
+
410
+ expect(inbox.messages.length).toBe(0);
411
+ });
412
+
413
+ it("returns null for non-existent message", async () => {
414
+ const message = await readAgentMessage({
415
+ projectPath: TEST_PROJECT_PATH,
416
+ messageId: 99999,
417
+ });
418
+
419
+ expect(message).toBeNull();
420
+ });
421
+ });
422
+
423
+ // ==========================================================================
424
+ // reserveAgentFiles (agentmail_reserve)
425
+ // ==========================================================================
426
+
427
+ describe("reserveAgentFiles", () => {
428
+ it("grants reservations", async () => {
429
+ const agent = await initAgent({
430
+ projectPath: TEST_PROJECT_PATH,
431
+ agentName: "Worker",
432
+ });
433
+
434
+ const result = await reserveAgentFiles({
435
+ projectPath: TEST_PROJECT_PATH,
436
+ agentName: agent.agentName,
437
+ paths: ["src/auth/**", "src/config.ts"],
438
+ reason: "bd-123: Working on auth",
439
+ });
440
+
441
+ expect(result.granted.length).toBe(2);
442
+ expect(result.conflicts.length).toBe(0);
443
+ });
444
+
445
+ it("detects conflicts with other agents", async () => {
446
+ const agent1 = await initAgent({
447
+ projectPath: TEST_PROJECT_PATH,
448
+ agentName: "Worker1",
449
+ });
450
+ const agent2 = await initAgent({
451
+ projectPath: TEST_PROJECT_PATH,
452
+ agentName: "Worker2",
453
+ });
454
+
455
+ // Agent 1 reserves
456
+ await reserveAgentFiles({
457
+ projectPath: TEST_PROJECT_PATH,
458
+ agentName: agent1.agentName,
459
+ paths: ["src/shared.ts"],
460
+ exclusive: true,
461
+ });
462
+
463
+ // Agent 2 tries to reserve same file
464
+ const result = await reserveAgentFiles({
465
+ projectPath: TEST_PROJECT_PATH,
466
+ agentName: agent2.agentName,
467
+ paths: ["src/shared.ts"],
468
+ exclusive: true,
469
+ });
470
+
471
+ expect(result.conflicts.length).toBe(1);
472
+ expect(result.conflicts[0]?.holder).toBe("Worker1");
473
+ });
474
+
475
+ it("allows non-exclusive reservations without conflict", async () => {
476
+ const agent1 = await initAgent({
477
+ projectPath: TEST_PROJECT_PATH,
478
+ agentName: "Worker1",
479
+ });
480
+ const agent2 = await initAgent({
481
+ projectPath: TEST_PROJECT_PATH,
482
+ agentName: "Worker2",
483
+ });
484
+
485
+ // Agent 1 reserves non-exclusively
486
+ await reserveAgentFiles({
487
+ projectPath: TEST_PROJECT_PATH,
488
+ agentName: agent1.agentName,
489
+ paths: ["src/shared.ts"],
490
+ exclusive: false,
491
+ });
492
+
493
+ // Agent 2 should not see conflict
494
+ const result = await reserveAgentFiles({
495
+ projectPath: TEST_PROJECT_PATH,
496
+ agentName: agent2.agentName,
497
+ paths: ["src/shared.ts"],
498
+ exclusive: true,
499
+ });
500
+
501
+ expect(result.conflicts.length).toBe(0);
502
+ });
503
+
504
+ it("supports TTL for auto-expiry", async () => {
505
+ const agent = await initAgent({
506
+ projectPath: TEST_PROJECT_PATH,
507
+ agentName: "Worker",
508
+ });
509
+
510
+ const result = await reserveAgentFiles({
511
+ projectPath: TEST_PROJECT_PATH,
512
+ agentName: agent.agentName,
513
+ paths: ["src/temp.ts"],
514
+ ttlSeconds: 3600,
515
+ });
516
+
517
+ expect(result.granted[0]?.expiresAt).toBeGreaterThan(Date.now());
518
+ });
519
+
520
+ it("rejects reservation when conflicts exist (THE FIX)", async () => {
521
+ const agent1 = await initAgent({
522
+ projectPath: TEST_PROJECT_PATH,
523
+ agentName: "Agent1",
524
+ });
525
+ const agent2 = await initAgent({
526
+ projectPath: TEST_PROJECT_PATH,
527
+ agentName: "Agent2",
528
+ });
529
+
530
+ // Agent1 reserves src/**
531
+ await reserveAgentFiles({
532
+ projectPath: TEST_PROJECT_PATH,
533
+ agentName: agent1.agentName,
534
+ paths: ["src/**"],
535
+ reason: "bd-123: Working on src",
536
+ });
537
+
538
+ // Agent2 tries to reserve src/file.ts - should be rejected
539
+ const result = await reserveAgentFiles({
540
+ projectPath: TEST_PROJECT_PATH,
541
+ agentName: agent2.agentName,
542
+ paths: ["src/file.ts"],
543
+ reason: "bd-124: Trying to edit file",
544
+ });
545
+
546
+ // No reservations granted
547
+ expect(result.granted).toHaveLength(0);
548
+ // But conflicts reported
549
+ expect(result.conflicts).toHaveLength(1);
550
+ expect(result.conflicts[0]?.holder).toBe("Agent1");
551
+ expect(result.conflicts[0]?.pattern).toBe("src/**");
552
+ expect(result.conflicts[0]?.path).toBe("src/file.ts");
553
+ });
554
+
555
+ it("allows reservation with force=true despite conflicts", async () => {
556
+ const agent1 = await initAgent({
557
+ projectPath: TEST_PROJECT_PATH,
558
+ agentName: "Agent1",
559
+ });
560
+ const agent2 = await initAgent({
561
+ projectPath: TEST_PROJECT_PATH,
562
+ agentName: "Agent2",
563
+ });
564
+
565
+ // Agent1 reserves src/**
566
+ await reserveAgentFiles({
567
+ projectPath: TEST_PROJECT_PATH,
568
+ agentName: agent1.agentName,
569
+ paths: ["src/**"],
570
+ reason: "bd-123: Working on src",
571
+ });
572
+
573
+ // Agent2 forces reservation despite conflict
574
+ const result = await reserveAgentFiles({
575
+ projectPath: TEST_PROJECT_PATH,
576
+ agentName: agent2.agentName,
577
+ paths: ["src/file.ts"],
578
+ reason: "bd-124: Emergency fix",
579
+ force: true,
580
+ });
581
+
582
+ // Reservation granted with force
583
+ expect(result.granted).toHaveLength(1);
584
+ expect(result.granted[0]?.path).toBe("src/file.ts");
585
+ // Conflicts still reported
586
+ expect(result.conflicts).toHaveLength(1);
587
+ expect(result.conflicts[0]?.holder).toBe("Agent1");
588
+ });
589
+
590
+ it("grants reservation when no conflicts exist", async () => {
591
+ const agent = await initAgent({
592
+ projectPath: TEST_PROJECT_PATH,
593
+ agentName: "Agent1",
594
+ });
595
+
596
+ // First reservation - no conflicts
597
+ const result = await reserveAgentFiles({
598
+ projectPath: TEST_PROJECT_PATH,
599
+ agentName: agent.agentName,
600
+ paths: ["src/new-file.ts"],
601
+ reason: "bd-125: Creating new file",
602
+ });
603
+
604
+ expect(result.granted).toHaveLength(1);
605
+ expect(result.conflicts).toHaveLength(0);
606
+ });
607
+
608
+ it("rejects multiple conflicting paths atomically", async () => {
609
+ const agent1 = await initAgent({
610
+ projectPath: TEST_PROJECT_PATH,
611
+ agentName: "Agent1",
612
+ });
613
+ const agent2 = await initAgent({
614
+ projectPath: TEST_PROJECT_PATH,
615
+ agentName: "Agent2",
616
+ });
617
+
618
+ // Agent1 reserves multiple paths
619
+ await reserveAgentFiles({
620
+ projectPath: TEST_PROJECT_PATH,
621
+ agentName: agent1.agentName,
622
+ paths: ["src/a.ts", "src/b.ts"],
623
+ });
624
+
625
+ // Agent2 tries to reserve same paths - all should be rejected
626
+ const result = await reserveAgentFiles({
627
+ projectPath: TEST_PROJECT_PATH,
628
+ agentName: agent2.agentName,
629
+ paths: ["src/a.ts", "src/b.ts", "src/c.ts"], // Mix of conflicts + available
630
+ });
631
+
632
+ // No reservations granted (even for src/c.ts)
633
+ expect(result.granted).toHaveLength(0);
634
+ // Conflicts for the reserved paths
635
+ expect(result.conflicts.length).toBeGreaterThan(0);
636
+ });
637
+ });
638
+
639
+ // ==========================================================================
640
+ // releaseAgentFiles (agentmail_release)
641
+ // ==========================================================================
642
+
643
+ describe("releaseAgentFiles", () => {
644
+ it("releases all reservations for agent", async () => {
645
+ const agent = await initAgent({
646
+ projectPath: TEST_PROJECT_PATH,
647
+ agentName: "Worker",
648
+ });
649
+
650
+ await reserveAgentFiles({
651
+ projectPath: TEST_PROJECT_PATH,
652
+ agentName: agent.agentName,
653
+ paths: ["src/a.ts", "src/b.ts"],
654
+ });
655
+
656
+ const result = await releaseAgentFiles({
657
+ projectPath: TEST_PROJECT_PATH,
658
+ agentName: agent.agentName,
659
+ });
660
+
661
+ expect(result.released).toBe(2);
662
+ });
663
+
664
+ it("releases specific paths only", async () => {
665
+ const agent = await initAgent({
666
+ projectPath: TEST_PROJECT_PATH,
667
+ agentName: "Worker",
668
+ });
669
+
670
+ await reserveAgentFiles({
671
+ projectPath: TEST_PROJECT_PATH,
672
+ agentName: agent.agentName,
673
+ paths: ["src/a.ts", "src/b.ts"],
674
+ });
675
+
676
+ const result = await releaseAgentFiles({
677
+ projectPath: TEST_PROJECT_PATH,
678
+ agentName: agent.agentName,
679
+ paths: ["src/a.ts"],
680
+ });
681
+
682
+ expect(result.released).toBe(1);
683
+ });
684
+
685
+ it("allows other agents to reserve after release", async () => {
686
+ const agent1 = await initAgent({
687
+ projectPath: TEST_PROJECT_PATH,
688
+ agentName: "Worker1",
689
+ });
690
+ const agent2 = await initAgent({
691
+ projectPath: TEST_PROJECT_PATH,
692
+ agentName: "Worker2",
693
+ });
694
+
695
+ // Agent 1 reserves then releases
696
+ await reserveAgentFiles({
697
+ projectPath: TEST_PROJECT_PATH,
698
+ agentName: agent1.agentName,
699
+ paths: ["src/shared.ts"],
700
+ exclusive: true,
701
+ });
702
+ await releaseAgentFiles({
703
+ projectPath: TEST_PROJECT_PATH,
704
+ agentName: agent1.agentName,
705
+ });
706
+
707
+ // Agent 2 should be able to reserve
708
+ const result = await reserveAgentFiles({
709
+ projectPath: TEST_PROJECT_PATH,
710
+ agentName: agent2.agentName,
711
+ paths: ["src/shared.ts"],
712
+ exclusive: true,
713
+ });
714
+
715
+ expect(result.conflicts.length).toBe(0);
716
+ expect(result.granted.length).toBe(1);
717
+ });
718
+ });
719
+
720
+ // ==========================================================================
721
+ // acknowledgeMessage (agentmail_ack)
722
+ // ==========================================================================
723
+
724
+ describe("acknowledgeMessage", () => {
725
+ it("acknowledges a message", async () => {
726
+ const sender = await initAgent({
727
+ projectPath: TEST_PROJECT_PATH,
728
+ agentName: "Sender",
729
+ });
730
+ const receiver = await initAgent({
731
+ projectPath: TEST_PROJECT_PATH,
732
+ agentName: "Receiver",
733
+ });
734
+
735
+ const sent = await sendAgentMessage({
736
+ projectPath: TEST_PROJECT_PATH,
737
+ fromAgent: sender.agentName,
738
+ toAgents: [receiver.agentName],
739
+ subject: "Please ack",
740
+ body: "Body",
741
+ ackRequired: true,
742
+ });
743
+
744
+ const result = await acknowledgeMessage({
745
+ projectPath: TEST_PROJECT_PATH,
746
+ messageId: sent.messageId,
747
+ agentName: receiver.agentName,
748
+ });
749
+
750
+ expect(result.acknowledged).toBe(true);
751
+ expect(result.acknowledgedAt).toBeTruthy();
752
+ });
753
+ });
754
+
755
+ // ==========================================================================
756
+ // checkHealth (agentmail_health)
757
+ // ==========================================================================
758
+
759
+ describe("checkHealth", () => {
760
+ it("returns healthy when database is accessible", async () => {
761
+ const health = await checkHealth(TEST_PROJECT_PATH);
762
+
763
+ expect(health.healthy).toBe(true);
764
+ expect(health.database).toBe("connected");
765
+ });
766
+
767
+ it("returns stats about the store", async () => {
768
+ // Create some data
769
+ await initAgent({ projectPath: TEST_PROJECT_PATH, agentName: "Agent1" });
770
+ await initAgent({ projectPath: TEST_PROJECT_PATH, agentName: "Agent2" });
771
+
772
+ const health = await checkHealth(TEST_PROJECT_PATH);
773
+
774
+ expect(health.stats?.agents).toBe(2);
775
+ });
776
+ });
777
+ });