opencode-swarm-plugin 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.
@@ -0,0 +1,1321 @@
1
+ /**
2
+ * Integration tests for agent-mail.ts
3
+ *
4
+ * These tests run against a real Agent Mail server (typically in Docker).
5
+ * Set AGENT_MAIL_URL environment variable to override the default server location.
6
+ *
7
+ * Run with: pnpm test:integration
8
+ * Or in Docker: docker compose up --build --abort-on-container-exit
9
+ */
10
+
11
+ import { describe, it, expect, beforeAll } from "vitest";
12
+ import {
13
+ mcpCall,
14
+ sessionStates,
15
+ setState,
16
+ clearState,
17
+ requireState,
18
+ MAX_INBOX_LIMIT,
19
+ AgentMailNotInitializedError,
20
+ type AgentMailState,
21
+ } from "./agent-mail";
22
+
23
+ // ============================================================================
24
+ // Test Configuration
25
+ // ============================================================================
26
+
27
+ const AGENT_MAIL_URL = process.env.AGENT_MAIL_URL || "http://127.0.0.1:8765";
28
+
29
+ /**
30
+ * Generate a unique test context to avoid state collisions between tests
31
+ */
32
+ function createTestContext() {
33
+ const id = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
34
+ return {
35
+ sessionID: id,
36
+ projectKey: `/test/project-${id}`,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Initialize a test agent and return its state
42
+ */
43
+ async function initTestAgent(
44
+ ctx: { sessionID: string; projectKey: string },
45
+ agentName?: string,
46
+ ) {
47
+ // Ensure project exists
48
+ const project = await mcpCall<{
49
+ id: number;
50
+ slug: string;
51
+ human_key: string;
52
+ }>("ensure_project", { human_key: ctx.projectKey });
53
+
54
+ // Register agent
55
+ const agent = await mcpCall<{
56
+ id: number;
57
+ name: string;
58
+ program: string;
59
+ model: string;
60
+ task_description: string;
61
+ }>("register_agent", {
62
+ project_key: ctx.projectKey,
63
+ program: "opencode-test",
64
+ model: "test-model",
65
+ name: agentName,
66
+ task_description: "Integration test agent",
67
+ });
68
+
69
+ // Store state
70
+ const state: AgentMailState = {
71
+ projectKey: ctx.projectKey,
72
+ agentName: agent.name,
73
+ reservations: [],
74
+ startedAt: new Date().toISOString(),
75
+ };
76
+ setState(ctx.sessionID, state);
77
+
78
+ return { project, agent, state };
79
+ }
80
+
81
+ // ============================================================================
82
+ // Health Check Tests
83
+ // ============================================================================
84
+
85
+ describe("agent-mail integration", () => {
86
+ beforeAll(async () => {
87
+ // Verify server is reachable before running tests
88
+ const response = await fetch(`${AGENT_MAIL_URL}/health/liveness`);
89
+ if (!response.ok) {
90
+ throw new Error(
91
+ `Agent Mail server not available at ${AGENT_MAIL_URL}. ` +
92
+ `Start it with: docker compose up agent-mail`,
93
+ );
94
+ }
95
+ });
96
+
97
+ describe("agentmail_health", () => {
98
+ it("returns healthy when server is running", async () => {
99
+ const response = await fetch(`${AGENT_MAIL_URL}/health/liveness`);
100
+ expect(response.ok).toBe(true);
101
+ const data = await response.json();
102
+ // Real Agent Mail returns "alive" not "ok"
103
+ expect(data.status).toBe("alive");
104
+ });
105
+
106
+ it("returns ready when database is accessible", async () => {
107
+ const response = await fetch(`${AGENT_MAIL_URL}/health/readiness`);
108
+ expect(response.ok).toBe(true);
109
+ const data = await response.json();
110
+ expect(data.status).toBe("ready");
111
+ });
112
+ });
113
+
114
+ // ============================================================================
115
+ // Initialization Tests
116
+ // ============================================================================
117
+
118
+ describe("agentmail_init", () => {
119
+ it("creates project and registers agent", async () => {
120
+ const ctx = createTestContext();
121
+
122
+ const { project, agent, state } = await initTestAgent(ctx);
123
+
124
+ expect(project.id).toBeGreaterThan(0);
125
+ expect(project.human_key).toBe(ctx.projectKey);
126
+ expect(agent.id).toBeGreaterThan(0);
127
+ expect(agent.name).toBeTruthy();
128
+ expect(state.projectKey).toBe(ctx.projectKey);
129
+ expect(state.agentName).toBe(agent.name);
130
+
131
+ // Cleanup
132
+ clearState(ctx.sessionID);
133
+ });
134
+
135
+ it("generates unique agent name when not provided", async () => {
136
+ const ctx = createTestContext();
137
+
138
+ const { agent: agent1 } = await initTestAgent(ctx);
139
+ clearState(ctx.sessionID);
140
+
141
+ // Register another agent without name
142
+ const ctx2 = { ...createTestContext(), projectKey: ctx.projectKey };
143
+ const { agent: agent2 } = await initTestAgent(ctx2);
144
+
145
+ // Both should have adjective+noun style names
146
+ expect(agent1.name).toMatch(/^[A-Z][a-z]+[A-Z][a-z]+$/);
147
+ expect(agent2.name).toMatch(/^[A-Z][a-z]+[A-Z][a-z]+$/);
148
+
149
+ // Cleanup
150
+ clearState(ctx.sessionID);
151
+ clearState(ctx2.sessionID);
152
+ });
153
+
154
+ it("uses provided agent name when specified (valid adjective+noun)", async () => {
155
+ const ctx = createTestContext();
156
+ // Server has a specific word list - use known-valid combinations
157
+ // Valid: BlueLake, GreenDog, RedStone, BlueBear
158
+ const customName = "BlueLake";
159
+
160
+ const { agent } = await initTestAgent(ctx, customName);
161
+
162
+ expect(agent.name).toBe(customName);
163
+
164
+ // Cleanup
165
+ clearState(ctx.sessionID);
166
+ });
167
+
168
+ it("re-registering same name updates existing agent (dedup by name)", async () => {
169
+ // Note: Real Agent Mail deduplicates by name within a project
170
+ // Re-registering with same name updates the existing agent (same ID)
171
+ const ctx = createTestContext();
172
+ // Use a valid name from the server's word list
173
+ const customName = "GreenDog";
174
+
175
+ const { agent: agent1 } = await initTestAgent(ctx, customName);
176
+ clearState(ctx.sessionID);
177
+
178
+ // Re-register with same name - updates existing agent
179
+ const ctx2 = { ...createTestContext(), projectKey: ctx.projectKey };
180
+ const { agent: agent2 } = await initTestAgent(ctx2, customName);
181
+
182
+ // Same name, same ID (updated, not duplicated)
183
+ expect(agent1.name).toBe(agent2.name);
184
+ expect(agent1.id).toBe(agent2.id);
185
+
186
+ // Cleanup
187
+ clearState(ctx2.sessionID);
188
+ });
189
+ });
190
+
191
+ // ============================================================================
192
+ // State Management Tests
193
+ // ============================================================================
194
+
195
+ describe("state management", () => {
196
+ it("requireState throws when not initialized", () => {
197
+ const sessionID = "nonexistent-session";
198
+
199
+ expect(() => requireState(sessionID)).toThrow(
200
+ AgentMailNotInitializedError,
201
+ );
202
+ });
203
+
204
+ it("requireState returns state when initialized", async () => {
205
+ const ctx = createTestContext();
206
+ await initTestAgent(ctx);
207
+
208
+ const state = requireState(ctx.sessionID);
209
+ expect(state.projectKey).toBe(ctx.projectKey);
210
+
211
+ // Cleanup
212
+ clearState(ctx.sessionID);
213
+ });
214
+
215
+ it("clearState removes session state", async () => {
216
+ const ctx = createTestContext();
217
+ await initTestAgent(ctx);
218
+
219
+ expect(sessionStates.has(ctx.sessionID)).toBe(true);
220
+ clearState(ctx.sessionID);
221
+ expect(sessionStates.has(ctx.sessionID)).toBe(false);
222
+ });
223
+ });
224
+
225
+ // ============================================================================
226
+ // Messaging Tests
227
+ // ============================================================================
228
+
229
+ describe("agentmail_send", () => {
230
+ it("sends message to another agent", async () => {
231
+ const ctx = createTestContext();
232
+ const { state: senderState } = await initTestAgent(
233
+ ctx,
234
+ `Sender_${Date.now()}`,
235
+ );
236
+
237
+ // Create recipient agent
238
+ const recipientCtx = {
239
+ ...createTestContext(),
240
+ projectKey: ctx.projectKey,
241
+ };
242
+ const { state: recipientState } = await initTestAgent(
243
+ recipientCtx,
244
+ `Recipient_${Date.now()}`,
245
+ );
246
+
247
+ // Send message
248
+ // Real Agent Mail returns { deliveries: [{ payload: { id, subject, ... } }], count }
249
+ const result = await mcpCall<{
250
+ deliveries: Array<{
251
+ payload: { id: number; subject: string; to: string[] };
252
+ }>;
253
+ count: number;
254
+ }>("send_message", {
255
+ project_key: senderState.projectKey,
256
+ sender_name: senderState.agentName,
257
+ to: [recipientState.agentName],
258
+ subject: "Test message",
259
+ body_md: "This is a test message body",
260
+ thread_id: "bd-test-123",
261
+ importance: "normal",
262
+ ack_required: false,
263
+ });
264
+
265
+ expect(result.count).toBe(1);
266
+ expect(result.deliveries[0].payload.id).toBeGreaterThan(0);
267
+ expect(result.deliveries[0].payload.subject).toBe("Test message");
268
+ expect(result.deliveries[0].payload.to).toContain(
269
+ recipientState.agentName,
270
+ );
271
+
272
+ // Cleanup
273
+ clearState(ctx.sessionID);
274
+ clearState(recipientCtx.sessionID);
275
+ });
276
+
277
+ it("sends urgent message with ack_required", async () => {
278
+ const ctx = createTestContext();
279
+ const { state: senderState } = await initTestAgent(
280
+ ctx,
281
+ `UrgentSender_${Date.now()}`,
282
+ );
283
+
284
+ const recipientCtx = {
285
+ ...createTestContext(),
286
+ projectKey: ctx.projectKey,
287
+ };
288
+ const { state: recipientState } = await initTestAgent(
289
+ recipientCtx,
290
+ `UrgentRecipient_${Date.now()}`,
291
+ );
292
+
293
+ // Real Agent Mail returns { deliveries: [...], count }
294
+ const result = await mcpCall<{
295
+ deliveries: Array<{ payload: { id: number } }>;
296
+ count: number;
297
+ }>("send_message", {
298
+ project_key: senderState.projectKey,
299
+ sender_name: senderState.agentName,
300
+ to: [recipientState.agentName],
301
+ subject: "Urgent: Action required",
302
+ body_md: "Please acknowledge this message",
303
+ importance: "urgent",
304
+ ack_required: true,
305
+ });
306
+
307
+ expect(result.count).toBe(1);
308
+ expect(result.deliveries[0].payload.id).toBeGreaterThan(0);
309
+
310
+ // Cleanup
311
+ clearState(ctx.sessionID);
312
+ clearState(recipientCtx.sessionID);
313
+ });
314
+ });
315
+
316
+ // ============================================================================
317
+ // Inbox Tests
318
+ // ============================================================================
319
+
320
+ describe("agentmail_inbox", () => {
321
+ it("fetches messages without bodies by default (context-safe)", async () => {
322
+ const ctx = createTestContext();
323
+ const { state: senderState } = await initTestAgent(
324
+ ctx,
325
+ `InboxSender_${Date.now()}`,
326
+ );
327
+
328
+ const recipientCtx = {
329
+ ...createTestContext(),
330
+ projectKey: ctx.projectKey,
331
+ };
332
+ const { state: recipientState } = await initTestAgent(
333
+ recipientCtx,
334
+ `InboxRecipient_${Date.now()}`,
335
+ );
336
+
337
+ // Send a message
338
+ await mcpCall("send_message", {
339
+ project_key: senderState.projectKey,
340
+ sender_name: senderState.agentName,
341
+ to: [recipientState.agentName],
342
+ subject: "Inbox test message",
343
+ body_md: "This body should NOT be included by default",
344
+ });
345
+
346
+ // Fetch inbox WITHOUT bodies
347
+ // Real Agent Mail returns { result: [...] } wrapper
348
+ const response = await mcpCall<{
349
+ result: Array<{
350
+ id: number;
351
+ subject: string;
352
+ from: string;
353
+ body_md?: string;
354
+ }>;
355
+ }>("fetch_inbox", {
356
+ project_key: recipientState.projectKey,
357
+ agent_name: recipientState.agentName,
358
+ limit: 5,
359
+ include_bodies: false, // MANDATORY context-safe default
360
+ });
361
+
362
+ const messages = response.result;
363
+ expect(messages.length).toBeGreaterThan(0);
364
+ const testMsg = messages.find((m) => m.subject === "Inbox test message");
365
+ expect(testMsg).toBeDefined();
366
+ expect(testMsg?.from).toBe(senderState.agentName);
367
+ // Body should NOT be included when include_bodies: false
368
+ expect(testMsg?.body_md).toBeUndefined();
369
+
370
+ // Cleanup
371
+ clearState(ctx.sessionID);
372
+ clearState(recipientCtx.sessionID);
373
+ });
374
+
375
+ it("enforces MAX_INBOX_LIMIT constraint", async () => {
376
+ const ctx = createTestContext();
377
+ const { state: senderState } = await initTestAgent(
378
+ ctx,
379
+ `LimitSender_${Date.now()}`,
380
+ );
381
+
382
+ const recipientCtx = {
383
+ ...createTestContext(),
384
+ projectKey: ctx.projectKey,
385
+ };
386
+ const { state: recipientState } = await initTestAgent(
387
+ recipientCtx,
388
+ `LimitRecipient_${Date.now()}`,
389
+ );
390
+
391
+ // Send more messages than MAX_INBOX_LIMIT
392
+ const messageCount = MAX_INBOX_LIMIT + 3;
393
+ for (let i = 0; i < messageCount; i++) {
394
+ await mcpCall("send_message", {
395
+ project_key: senderState.projectKey,
396
+ sender_name: senderState.agentName,
397
+ to: [recipientState.agentName],
398
+ subject: `Limit test message ${i}`,
399
+ body_md: `Message body ${i}`,
400
+ });
401
+ }
402
+
403
+ // Request more than MAX_INBOX_LIMIT
404
+ const response = await mcpCall<{ result: Array<{ id: number }> }>(
405
+ "fetch_inbox",
406
+ {
407
+ project_key: recipientState.projectKey,
408
+ agent_name: recipientState.agentName,
409
+ limit: messageCount, // Request more than allowed
410
+ include_bodies: false,
411
+ },
412
+ );
413
+
414
+ // Should still return the requested amount from server
415
+ // The constraint enforcement happens in the tool wrapper, not mcpCall
416
+ expect(response.result.length).toBeGreaterThanOrEqual(MAX_INBOX_LIMIT);
417
+
418
+ // Cleanup
419
+ clearState(ctx.sessionID);
420
+ clearState(recipientCtx.sessionID);
421
+ });
422
+
423
+ it("filters urgent messages when urgent_only is true", async () => {
424
+ const ctx = createTestContext();
425
+ const { state: senderState } = await initTestAgent(
426
+ ctx,
427
+ `UrgentFilterSender_${Date.now()}`,
428
+ );
429
+
430
+ const recipientCtx = {
431
+ ...createTestContext(),
432
+ projectKey: ctx.projectKey,
433
+ };
434
+ const { state: recipientState } = await initTestAgent(
435
+ recipientCtx,
436
+ `UrgentFilterRecipient_${Date.now()}`,
437
+ );
438
+
439
+ // Send normal and urgent messages
440
+ await mcpCall("send_message", {
441
+ project_key: senderState.projectKey,
442
+ sender_name: senderState.agentName,
443
+ to: [recipientState.agentName],
444
+ subject: "Normal message",
445
+ body_md: "Not urgent",
446
+ importance: "normal",
447
+ });
448
+
449
+ await mcpCall("send_message", {
450
+ project_key: senderState.projectKey,
451
+ sender_name: senderState.agentName,
452
+ to: [recipientState.agentName],
453
+ subject: "Urgent message",
454
+ body_md: "Very urgent!",
455
+ importance: "urgent",
456
+ });
457
+
458
+ // Fetch only urgent messages
459
+ const response = await mcpCall<{
460
+ result: Array<{ subject: string; importance: string }>;
461
+ }>("fetch_inbox", {
462
+ project_key: recipientState.projectKey,
463
+ agent_name: recipientState.agentName,
464
+ limit: 10,
465
+ include_bodies: false,
466
+ urgent_only: true,
467
+ });
468
+
469
+ const messages = response.result;
470
+ // All returned messages should be urgent
471
+ for (const msg of messages) {
472
+ expect(msg.importance).toBe("urgent");
473
+ }
474
+ expect(messages.some((m) => m.subject === "Urgent message")).toBe(true);
475
+
476
+ // Cleanup
477
+ clearState(ctx.sessionID);
478
+ clearState(recipientCtx.sessionID);
479
+ });
480
+
481
+ it("filters by since_ts timestamp", async () => {
482
+ const ctx = createTestContext();
483
+ const { state: senderState } = await initTestAgent(
484
+ ctx,
485
+ `TimeSender_${Date.now()}`,
486
+ );
487
+
488
+ const recipientCtx = {
489
+ ...createTestContext(),
490
+ projectKey: ctx.projectKey,
491
+ };
492
+ const { state: recipientState } = await initTestAgent(
493
+ recipientCtx,
494
+ `TimeRecipient_${Date.now()}`,
495
+ );
496
+
497
+ // Send first message
498
+ await mcpCall("send_message", {
499
+ project_key: senderState.projectKey,
500
+ sender_name: senderState.agentName,
501
+ to: [recipientState.agentName],
502
+ subject: "Old message",
503
+ body_md: "Sent before timestamp",
504
+ });
505
+
506
+ // Wait a moment and capture timestamp
507
+ await new Promise((resolve) => setTimeout(resolve, 100));
508
+ const sinceTs = new Date().toISOString();
509
+ await new Promise((resolve) => setTimeout(resolve, 100));
510
+
511
+ // Send second message after timestamp
512
+ await mcpCall("send_message", {
513
+ project_key: senderState.projectKey,
514
+ sender_name: senderState.agentName,
515
+ to: [recipientState.agentName],
516
+ subject: "New message",
517
+ body_md: "Sent after timestamp",
518
+ });
519
+
520
+ // Fetch only messages after timestamp
521
+ const response = await mcpCall<{
522
+ result: Array<{ subject: string }>;
523
+ }>("fetch_inbox", {
524
+ project_key: recipientState.projectKey,
525
+ agent_name: recipientState.agentName,
526
+ limit: 10,
527
+ include_bodies: false,
528
+ since_ts: sinceTs,
529
+ });
530
+
531
+ const messages = response.result;
532
+ expect(messages.some((m) => m.subject === "New message")).toBe(true);
533
+ expect(messages.some((m) => m.subject === "Old message")).toBe(false);
534
+
535
+ // Cleanup
536
+ clearState(ctx.sessionID);
537
+ clearState(recipientCtx.sessionID);
538
+ });
539
+ });
540
+
541
+ // ============================================================================
542
+ // Read Message Tests
543
+ // ============================================================================
544
+
545
+ describe("agentmail_read_message", () => {
546
+ it("marks message as read", async () => {
547
+ const ctx = createTestContext();
548
+ const { state: senderState } = await initTestAgent(
549
+ ctx,
550
+ `ReadSender_${Date.now()}`,
551
+ );
552
+
553
+ const recipientCtx = {
554
+ ...createTestContext(),
555
+ projectKey: ctx.projectKey,
556
+ };
557
+ const { state: recipientState } = await initTestAgent(
558
+ recipientCtx,
559
+ `ReadRecipient_${Date.now()}`,
560
+ );
561
+
562
+ // Send a message
563
+ // Real Agent Mail returns { deliveries: [{ payload: { id, ... } }] }
564
+ const sentMsg = await mcpCall<{
565
+ deliveries: Array<{ payload: { id: number } }>;
566
+ }>("send_message", {
567
+ project_key: senderState.projectKey,
568
+ sender_name: senderState.agentName,
569
+ to: [recipientState.agentName],
570
+ subject: "Read test message",
571
+ body_md: "This message will be marked as read",
572
+ });
573
+
574
+ const messageId = sentMsg.deliveries[0].payload.id;
575
+
576
+ // Mark as read
577
+ // Real Agent Mail returns { message_id, read: bool, read_at: iso8601 | null }
578
+ const result = await mcpCall<{
579
+ message_id: number;
580
+ read: boolean;
581
+ read_at: string | null;
582
+ }>("mark_message_read", {
583
+ project_key: recipientState.projectKey,
584
+ agent_name: recipientState.agentName,
585
+ message_id: messageId,
586
+ });
587
+
588
+ expect(result.message_id).toBe(messageId);
589
+ expect(result.read).toBe(true);
590
+ expect(result.read_at).toBeTruthy();
591
+
592
+ // Cleanup
593
+ clearState(ctx.sessionID);
594
+ clearState(recipientCtx.sessionID);
595
+ });
596
+ });
597
+
598
+ // ============================================================================
599
+ // Thread Summary Tests
600
+ // ============================================================================
601
+
602
+ describe("agentmail_summarize_thread", () => {
603
+ // Skip: summarize_thread requires LLM which may not be available
604
+ it.skip("summarizes messages in a thread", async () => {
605
+ const ctx = createTestContext();
606
+ const { state: senderState } = await initTestAgent(
607
+ ctx,
608
+ `ThreadSender_${Date.now()}`,
609
+ );
610
+
611
+ const recipientCtx = {
612
+ ...createTestContext(),
613
+ projectKey: ctx.projectKey,
614
+ };
615
+ const { state: recipientState } = await initTestAgent(
616
+ recipientCtx,
617
+ `ThreadRecipient_${Date.now()}`,
618
+ );
619
+
620
+ const threadId = `thread-${Date.now()}`;
621
+
622
+ // Send multiple messages in the same thread
623
+ await mcpCall("send_message", {
624
+ project_key: senderState.projectKey,
625
+ sender_name: senderState.agentName,
626
+ to: [recipientState.agentName],
627
+ subject: "Thread message 1",
628
+ body_md: "First message in thread",
629
+ thread_id: threadId,
630
+ });
631
+
632
+ await mcpCall("send_message", {
633
+ project_key: senderState.projectKey,
634
+ sender_name: senderState.agentName,
635
+ to: [recipientState.agentName],
636
+ subject: "Thread message 2",
637
+ body_md: "Second message in thread",
638
+ thread_id: threadId,
639
+ });
640
+
641
+ // Get thread summary
642
+ const summary = await mcpCall<{
643
+ thread_id: string;
644
+ summary: {
645
+ participants: string[];
646
+ key_points: string[];
647
+ action_items: string[];
648
+ total_messages: number;
649
+ };
650
+ }>("summarize_thread", {
651
+ project_key: senderState.projectKey,
652
+ thread_id: threadId,
653
+ include_examples: false,
654
+ });
655
+
656
+ expect(summary.thread_id).toBe(threadId);
657
+ expect(summary.summary.participants).toContain(senderState.agentName);
658
+ expect(summary.summary.total_messages).toBe(2);
659
+ expect(summary.summary.key_points.length).toBeGreaterThan(0);
660
+
661
+ // Cleanup
662
+ clearState(ctx.sessionID);
663
+ clearState(recipientCtx.sessionID);
664
+ });
665
+
666
+ // Skip: summarize_thread requires LLM which may not be available
667
+ it.skip("includes example messages when requested", async () => {
668
+ const ctx = createTestContext();
669
+ const { state: senderState } = await initTestAgent(
670
+ ctx,
671
+ `ExampleSender_${Date.now()}`,
672
+ );
673
+
674
+ const recipientCtx = {
675
+ ...createTestContext(),
676
+ projectKey: ctx.projectKey,
677
+ };
678
+ const { state: recipientState } = await initTestAgent(
679
+ recipientCtx,
680
+ `ExampleRecipient_${Date.now()}`,
681
+ );
682
+
683
+ const threadId = `example-thread-${Date.now()}`;
684
+
685
+ await mcpCall("send_message", {
686
+ project_key: senderState.projectKey,
687
+ sender_name: senderState.agentName,
688
+ to: [recipientState.agentName],
689
+ subject: "Example thread message",
690
+ body_md: "This should be in examples",
691
+ thread_id: threadId,
692
+ });
693
+
694
+ // Get summary with examples
695
+ const summary = await mcpCall<{
696
+ thread_id: string;
697
+ examples?: Array<{
698
+ id: number;
699
+ subject: string;
700
+ from: string;
701
+ body_md?: string;
702
+ }>;
703
+ }>("summarize_thread", {
704
+ project_key: senderState.projectKey,
705
+ thread_id: threadId,
706
+ include_examples: true,
707
+ });
708
+
709
+ expect(summary.examples).toBeDefined();
710
+ expect(summary.examples!.length).toBeGreaterThan(0);
711
+ expect(summary.examples![0].subject).toBe("Example thread message");
712
+ expect(summary.examples![0].body_md).toBe("This should be in examples");
713
+
714
+ // Cleanup
715
+ clearState(ctx.sessionID);
716
+ clearState(recipientCtx.sessionID);
717
+ });
718
+ });
719
+
720
+ // ============================================================================
721
+ // File Reservation Tests
722
+ // ============================================================================
723
+
724
+ describe("agentmail_reserve", () => {
725
+ it("grants file reservations", async () => {
726
+ const ctx = createTestContext();
727
+ const { state } = await initTestAgent(ctx, `ReserveAgent_${Date.now()}`);
728
+
729
+ const result = await mcpCall<{
730
+ granted: Array<{
731
+ id: number;
732
+ path_pattern: string;
733
+ exclusive: boolean;
734
+ reason: string;
735
+ expires_ts: string;
736
+ }>;
737
+ conflicts: Array<{ path: string; holders: string[] }>;
738
+ }>("file_reservation_paths", {
739
+ project_key: state.projectKey,
740
+ agent_name: state.agentName,
741
+ paths: ["src/auth/**", "src/config.ts"],
742
+ ttl_seconds: 3600,
743
+ exclusive: true,
744
+ reason: "bd-test-123: Working on auth",
745
+ });
746
+
747
+ expect(result.granted.length).toBe(2);
748
+ expect(result.conflicts.length).toBe(0);
749
+ expect(result.granted[0].exclusive).toBe(true);
750
+ expect(result.granted[0].reason).toContain("bd-test-123");
751
+
752
+ // Cleanup
753
+ clearState(ctx.sessionID);
754
+ });
755
+
756
+ it("detects conflicts with exclusive reservations", async () => {
757
+ const ctx = createTestContext();
758
+ const { state: agent1State } = await initTestAgent(
759
+ ctx,
760
+ `ConflictAgent1_${Date.now()}`,
761
+ );
762
+
763
+ const agent2Ctx = { ...createTestContext(), projectKey: ctx.projectKey };
764
+ const { state: agent2State } = await initTestAgent(
765
+ agent2Ctx,
766
+ `ConflictAgent2_${Date.now()}`,
767
+ );
768
+
769
+ const conflictPath = `src/conflict-${Date.now()}.ts`;
770
+
771
+ // Agent 1 reserves the file
772
+ const result1 = await mcpCall<{
773
+ granted: Array<{ id: number }>;
774
+ conflicts: Array<{ path: string; holders: string[] }>;
775
+ }>("file_reservation_paths", {
776
+ project_key: agent1State.projectKey,
777
+ agent_name: agent1State.agentName,
778
+ paths: [conflictPath],
779
+ ttl_seconds: 3600,
780
+ exclusive: true,
781
+ });
782
+
783
+ expect(result1.granted.length).toBe(1);
784
+ expect(result1.conflicts.length).toBe(0);
785
+
786
+ // Agent 2 tries to reserve the same file
787
+ // Real Agent Mail GRANTS the reservation but ALSO reports conflicts
788
+ // This is the expected behavior - it's a warning, not a block
789
+ const result2 = await mcpCall<{
790
+ granted: Array<{ id: number }>;
791
+ conflicts: Array<{
792
+ path: string;
793
+ holders: Array<{ agent: string; path_pattern: string }>;
794
+ }>;
795
+ }>("file_reservation_paths", {
796
+ project_key: agent2State.projectKey,
797
+ agent_name: agent2State.agentName,
798
+ paths: [conflictPath],
799
+ ttl_seconds: 3600,
800
+ exclusive: true,
801
+ });
802
+
803
+ // Server grants the reservation but reports conflicts
804
+ expect(result2.granted.length).toBe(1);
805
+ expect(result2.conflicts.length).toBe(1);
806
+ expect(result2.conflicts[0].path).toBe(conflictPath);
807
+ // holders is an array of objects with agent field
808
+ expect(
809
+ result2.conflicts[0].holders.some(
810
+ (h) => h.agent === agent1State.agentName,
811
+ ),
812
+ ).toBe(true);
813
+
814
+ // Cleanup
815
+ clearState(ctx.sessionID);
816
+ clearState(agent2Ctx.sessionID);
817
+ });
818
+
819
+ it("stores reservation IDs in state", async () => {
820
+ const ctx = createTestContext();
821
+ const { state } = await initTestAgent(ctx, `StateAgent_${Date.now()}`);
822
+
823
+ const result = await mcpCall<{
824
+ granted: Array<{ id: number }>;
825
+ }>("file_reservation_paths", {
826
+ project_key: state.projectKey,
827
+ agent_name: state.agentName,
828
+ paths: ["src/state-test.ts"],
829
+ ttl_seconds: 3600,
830
+ exclusive: true,
831
+ });
832
+
833
+ // Manually track reservations like the tool does
834
+ const reservationIds = result.granted.map((r) => r.id);
835
+ state.reservations = [...state.reservations, ...reservationIds];
836
+ setState(ctx.sessionID, state);
837
+
838
+ // Verify state was updated
839
+ const updatedState = requireState(ctx.sessionID);
840
+ expect(updatedState.reservations.length).toBeGreaterThan(0);
841
+ expect(updatedState.reservations).toContain(result.granted[0].id);
842
+
843
+ // Cleanup
844
+ clearState(ctx.sessionID);
845
+ });
846
+ });
847
+
848
+ // ============================================================================
849
+ // Release Reservation Tests
850
+ // ============================================================================
851
+
852
+ describe("agentmail_release", () => {
853
+ it("releases all reservations for an agent", async () => {
854
+ const ctx = createTestContext();
855
+ const { state } = await initTestAgent(ctx, `ReleaseAgent_${Date.now()}`);
856
+
857
+ // Create reservations
858
+ await mcpCall("file_reservation_paths", {
859
+ project_key: state.projectKey,
860
+ agent_name: state.agentName,
861
+ paths: ["src/release-test-1.ts", "src/release-test-2.ts"],
862
+ ttl_seconds: 3600,
863
+ exclusive: true,
864
+ });
865
+
866
+ // Release all
867
+ const result = await mcpCall<{ released: number; released_at: string }>(
868
+ "release_file_reservations",
869
+ {
870
+ project_key: state.projectKey,
871
+ agent_name: state.agentName,
872
+ },
873
+ );
874
+
875
+ expect(result.released).toBe(2);
876
+ expect(result.released_at).toBeTruthy();
877
+
878
+ // Cleanup
879
+ clearState(ctx.sessionID);
880
+ });
881
+
882
+ it("releases specific paths only", async () => {
883
+ const ctx = createTestContext();
884
+ const { state } = await initTestAgent(
885
+ ctx,
886
+ `SpecificReleaseAgent_${Date.now()}`,
887
+ );
888
+
889
+ const path1 = `src/specific-release-1-${Date.now()}.ts`;
890
+ const path2 = `src/specific-release-2-${Date.now()}.ts`;
891
+
892
+ // Create reservations
893
+ await mcpCall("file_reservation_paths", {
894
+ project_key: state.projectKey,
895
+ agent_name: state.agentName,
896
+ paths: [path1, path2],
897
+ ttl_seconds: 3600,
898
+ exclusive: true,
899
+ });
900
+
901
+ // Release only one path
902
+ const result = await mcpCall<{ released: number }>(
903
+ "release_file_reservations",
904
+ {
905
+ project_key: state.projectKey,
906
+ agent_name: state.agentName,
907
+ paths: [path1],
908
+ },
909
+ );
910
+
911
+ expect(result.released).toBe(1);
912
+
913
+ // Verify second path can still cause conflicts
914
+ const agent2Ctx = { ...createTestContext(), projectKey: ctx.projectKey };
915
+ const { state: agent2State } = await initTestAgent(
916
+ agent2Ctx,
917
+ `SpecificReleaseAgent2_${Date.now()}`,
918
+ );
919
+
920
+ const conflictResult = await mcpCall<{
921
+ conflicts: Array<{ path: string }>;
922
+ }>("file_reservation_paths", {
923
+ project_key: agent2State.projectKey,
924
+ agent_name: agent2State.agentName,
925
+ paths: [path2],
926
+ exclusive: true,
927
+ });
928
+
929
+ expect(conflictResult.conflicts.length).toBe(1);
930
+
931
+ // Cleanup
932
+ clearState(ctx.sessionID);
933
+ clearState(agent2Ctx.sessionID);
934
+ });
935
+
936
+ it("releases by reservation IDs", async () => {
937
+ const ctx = createTestContext();
938
+ const { state } = await initTestAgent(
939
+ ctx,
940
+ `IdReleaseAgent_${Date.now()}`,
941
+ );
942
+
943
+ // Create reservations
944
+ const reserveResult = await mcpCall<{
945
+ granted: Array<{ id: number }>;
946
+ }>("file_reservation_paths", {
947
+ project_key: state.projectKey,
948
+ agent_name: state.agentName,
949
+ paths: ["src/id-release-1.ts", "src/id-release-2.ts"],
950
+ ttl_seconds: 3600,
951
+ exclusive: true,
952
+ });
953
+
954
+ const firstId = reserveResult.granted[0].id;
955
+
956
+ // Release by ID
957
+ const result = await mcpCall<{ released: number }>(
958
+ "release_file_reservations",
959
+ {
960
+ project_key: state.projectKey,
961
+ agent_name: state.agentName,
962
+ file_reservation_ids: [firstId],
963
+ },
964
+ );
965
+
966
+ expect(result.released).toBe(1);
967
+
968
+ // Cleanup
969
+ clearState(ctx.sessionID);
970
+ });
971
+ });
972
+
973
+ // ============================================================================
974
+ // Acknowledge Message Tests
975
+ // ============================================================================
976
+
977
+ describe("agentmail_ack", () => {
978
+ it("acknowledges a message requiring acknowledgement", async () => {
979
+ const ctx = createTestContext();
980
+ const { state: senderState } = await initTestAgent(
981
+ ctx,
982
+ `AckSender_${Date.now()}`,
983
+ );
984
+
985
+ const recipientCtx = {
986
+ ...createTestContext(),
987
+ projectKey: ctx.projectKey,
988
+ };
989
+ const { state: recipientState } = await initTestAgent(
990
+ recipientCtx,
991
+ `AckRecipient_${Date.now()}`,
992
+ );
993
+
994
+ // Send message requiring ack
995
+ // Real Agent Mail returns { deliveries: [{ payload: { id, ... } }] }
996
+ const sentMsg = await mcpCall<{
997
+ deliveries: Array<{ payload: { id: number } }>;
998
+ }>("send_message", {
999
+ project_key: senderState.projectKey,
1000
+ sender_name: senderState.agentName,
1001
+ to: [recipientState.agentName],
1002
+ subject: "Please acknowledge",
1003
+ body_md: "This requires acknowledgement",
1004
+ ack_required: true,
1005
+ });
1006
+
1007
+ const messageId = sentMsg.deliveries[0].payload.id;
1008
+
1009
+ // Acknowledge
1010
+ // Real Agent Mail returns { acknowledged: bool, acknowledged_at: iso8601 | null }
1011
+ const result = await mcpCall<{
1012
+ message_id: number;
1013
+ acknowledged: boolean;
1014
+ acknowledged_at: string | null;
1015
+ }>("acknowledge_message", {
1016
+ project_key: recipientState.projectKey,
1017
+ agent_name: recipientState.agentName,
1018
+ message_id: messageId,
1019
+ });
1020
+
1021
+ expect(result.message_id).toBe(messageId);
1022
+ expect(result.acknowledged).toBe(true);
1023
+ expect(result.acknowledged_at).toBeTruthy();
1024
+
1025
+ // Cleanup
1026
+ clearState(ctx.sessionID);
1027
+ clearState(recipientCtx.sessionID);
1028
+ });
1029
+ });
1030
+
1031
+ // ============================================================================
1032
+ // Search Tests
1033
+ // ============================================================================
1034
+
1035
+ describe("agentmail_search", () => {
1036
+ it("searches messages by keyword using FTS5", async () => {
1037
+ const ctx = createTestContext();
1038
+ const { state: senderState } = await initTestAgent(
1039
+ ctx,
1040
+ `SearchSender_${Date.now()}`,
1041
+ );
1042
+
1043
+ const recipientCtx = {
1044
+ ...createTestContext(),
1045
+ projectKey: ctx.projectKey,
1046
+ };
1047
+ const { state: recipientState } = await initTestAgent(
1048
+ recipientCtx,
1049
+ `SearchRecipient_${Date.now()}`,
1050
+ );
1051
+
1052
+ const uniqueKeyword = `unicorn${Date.now()}`;
1053
+
1054
+ // Send messages with searchable content
1055
+ await mcpCall("send_message", {
1056
+ project_key: senderState.projectKey,
1057
+ sender_name: senderState.agentName,
1058
+ to: [recipientState.agentName],
1059
+ subject: `Message about ${uniqueKeyword}`,
1060
+ body_md: "This message contains the keyword",
1061
+ });
1062
+
1063
+ await mcpCall("send_message", {
1064
+ project_key: senderState.projectKey,
1065
+ sender_name: senderState.agentName,
1066
+ to: [recipientState.agentName],
1067
+ subject: "Unrelated message",
1068
+ body_md: "This message is about something else",
1069
+ });
1070
+
1071
+ // Search
1072
+ // Real Agent Mail returns { result: [...] }
1073
+ const response = await mcpCall<{
1074
+ result: Array<{ id: number; subject: string }>;
1075
+ }>("search_messages", {
1076
+ project_key: senderState.projectKey,
1077
+ query: uniqueKeyword,
1078
+ limit: 10,
1079
+ });
1080
+
1081
+ const results = response.result;
1082
+ expect(results.length).toBeGreaterThan(0);
1083
+ expect(results.every((r) => r.subject.includes(uniqueKeyword))).toBe(
1084
+ true,
1085
+ );
1086
+
1087
+ // Cleanup
1088
+ clearState(ctx.sessionID);
1089
+ clearState(recipientCtx.sessionID);
1090
+ });
1091
+
1092
+ it("respects search limit", async () => {
1093
+ const ctx = createTestContext();
1094
+ const { state: senderState } = await initTestAgent(
1095
+ ctx,
1096
+ `LimitSearchSender_${Date.now()}`,
1097
+ );
1098
+
1099
+ const recipientCtx = {
1100
+ ...createTestContext(),
1101
+ projectKey: ctx.projectKey,
1102
+ };
1103
+ const { state: recipientState } = await initTestAgent(
1104
+ recipientCtx,
1105
+ `LimitSearchRecipient_${Date.now()}`,
1106
+ );
1107
+
1108
+ const keyword = `searchlimit${Date.now()}`;
1109
+
1110
+ // Send multiple matching messages
1111
+ for (let i = 0; i < 5; i++) {
1112
+ await mcpCall("send_message", {
1113
+ project_key: senderState.projectKey,
1114
+ sender_name: senderState.agentName,
1115
+ to: [recipientState.agentName],
1116
+ subject: `${keyword} message ${i}`,
1117
+ body_md: `Content with ${keyword}`,
1118
+ });
1119
+ }
1120
+
1121
+ // Search with limit
1122
+ // Real Agent Mail returns { result: [...] }
1123
+ const response = await mcpCall<{
1124
+ result: Array<{ id: number }>;
1125
+ }>("search_messages", {
1126
+ project_key: senderState.projectKey,
1127
+ query: keyword,
1128
+ limit: 2,
1129
+ });
1130
+
1131
+ expect(response.result.length).toBe(2);
1132
+
1133
+ // Cleanup
1134
+ clearState(ctx.sessionID);
1135
+ clearState(recipientCtx.sessionID);
1136
+ });
1137
+ });
1138
+
1139
+ // ============================================================================
1140
+ // Error Handling Tests
1141
+ // ============================================================================
1142
+
1143
+ describe("error handling", () => {
1144
+ it("throws on unknown tool", async () => {
1145
+ // Real Agent Mail returns isError: true which mcpCall converts to throw
1146
+ await expect(mcpCall("nonexistent_tool", {})).rejects.toThrow(
1147
+ /Unknown tool/,
1148
+ );
1149
+ });
1150
+
1151
+ it("throws on missing required parameters", async () => {
1152
+ // Real Agent Mail returns validation error with isError: true
1153
+ await expect(mcpCall("ensure_project", {})).rejects.toThrow(
1154
+ /Missing required argument|validation error/,
1155
+ );
1156
+ });
1157
+
1158
+ it("throws on invalid project reference", async () => {
1159
+ // Real Agent Mail auto-creates projects, so this actually succeeds
1160
+ // Instead test with a truly invalid operation
1161
+ await expect(
1162
+ mcpCall("register_agent", {
1163
+ // Missing required project_key
1164
+ program: "test",
1165
+ model: "test",
1166
+ }),
1167
+ ).rejects.toThrow(/Missing required argument|validation error/);
1168
+ });
1169
+ });
1170
+
1171
+ // ============================================================================
1172
+ // Multi-Agent Coordination Tests
1173
+ // ============================================================================
1174
+
1175
+ describe("multi-agent coordination", () => {
1176
+ it("enables communication between multiple agents", async () => {
1177
+ const ctx = createTestContext();
1178
+
1179
+ // Create 3 agents in the same project
1180
+ const agent1Ctx = ctx;
1181
+ const { state: agent1 } = await initTestAgent(
1182
+ agent1Ctx,
1183
+ `Coordinator_${Date.now()}`,
1184
+ );
1185
+
1186
+ const agent2Ctx = { ...createTestContext(), projectKey: ctx.projectKey };
1187
+ const { state: agent2 } = await initTestAgent(
1188
+ agent2Ctx,
1189
+ `Worker1_${Date.now()}`,
1190
+ );
1191
+
1192
+ const agent3Ctx = { ...createTestContext(), projectKey: ctx.projectKey };
1193
+ const { state: agent3 } = await initTestAgent(
1194
+ agent3Ctx,
1195
+ `Worker2_${Date.now()}`,
1196
+ );
1197
+
1198
+ // Coordinator broadcasts to workers
1199
+ await mcpCall("send_message", {
1200
+ project_key: agent1.projectKey,
1201
+ sender_name: agent1.agentName,
1202
+ to: [agent2.agentName, agent3.agentName],
1203
+ subject: "Task assignment",
1204
+ body_md: "Please complete your subtasks",
1205
+ thread_id: "bd-epic-123",
1206
+ importance: "high",
1207
+ });
1208
+
1209
+ // Verify both workers received the message
1210
+ const worker1Response = await mcpCall<{
1211
+ result: Array<{ subject: string }>;
1212
+ }>("fetch_inbox", {
1213
+ project_key: agent2.projectKey,
1214
+ agent_name: agent2.agentName,
1215
+ limit: 5,
1216
+ include_bodies: false,
1217
+ });
1218
+
1219
+ const worker2Response = await mcpCall<{
1220
+ result: Array<{ subject: string }>;
1221
+ }>("fetch_inbox", {
1222
+ project_key: agent3.projectKey,
1223
+ agent_name: agent3.agentName,
1224
+ limit: 5,
1225
+ include_bodies: false,
1226
+ });
1227
+
1228
+ expect(
1229
+ worker1Response.result.some((m) => m.subject === "Task assignment"),
1230
+ ).toBe(true);
1231
+ expect(
1232
+ worker2Response.result.some((m) => m.subject === "Task assignment"),
1233
+ ).toBe(true);
1234
+
1235
+ // Cleanup
1236
+ clearState(agent1Ctx.sessionID);
1237
+ clearState(agent2Ctx.sessionID);
1238
+ clearState(agent3Ctx.sessionID);
1239
+ });
1240
+
1241
+ it("prevents file conflicts in swarm scenarios", async () => {
1242
+ const ctx = createTestContext();
1243
+
1244
+ // Coordinator assigns different files to workers
1245
+ const coordCtx = ctx;
1246
+ await initTestAgent(coordCtx, `SwarmCoord_${Date.now()}`);
1247
+
1248
+ const worker1Ctx = { ...createTestContext(), projectKey: ctx.projectKey };
1249
+ const { state: worker1 } = await initTestAgent(
1250
+ worker1Ctx,
1251
+ `SwarmWorker1_${Date.now()}`,
1252
+ );
1253
+
1254
+ const worker2Ctx = { ...createTestContext(), projectKey: ctx.projectKey };
1255
+ const { state: worker2 } = await initTestAgent(
1256
+ worker2Ctx,
1257
+ `SwarmWorker2_${Date.now()}`,
1258
+ );
1259
+
1260
+ const path1 = `src/swarm/file1-${Date.now()}.ts`;
1261
+ const path2 = `src/swarm/file2-${Date.now()}.ts`;
1262
+
1263
+ // Worker 1 reserves file 1
1264
+ const res1 = await mcpCall<{
1265
+ granted: Array<{ id: number }>;
1266
+ conflicts: unknown[];
1267
+ }>("file_reservation_paths", {
1268
+ project_key: worker1.projectKey,
1269
+ agent_name: worker1.agentName,
1270
+ paths: [path1],
1271
+ exclusive: true,
1272
+ reason: "bd-subtask-1",
1273
+ });
1274
+
1275
+ // Worker 2 reserves file 2
1276
+ const res2 = await mcpCall<{
1277
+ granted: Array<{ id: number }>;
1278
+ conflicts: unknown[];
1279
+ }>("file_reservation_paths", {
1280
+ project_key: worker2.projectKey,
1281
+ agent_name: worker2.agentName,
1282
+ paths: [path2],
1283
+ exclusive: true,
1284
+ reason: "bd-subtask-2",
1285
+ });
1286
+
1287
+ // Both should succeed (no conflicts)
1288
+ expect(res1.granted.length).toBe(1);
1289
+ expect(res1.conflicts.length).toBe(0);
1290
+ expect(res2.granted.length).toBe(1);
1291
+ expect(res2.conflicts.length).toBe(0);
1292
+
1293
+ // Worker 1 tries to reserve file 2 (should conflict)
1294
+ // Real Agent Mail returns holders as array of objects with agent field
1295
+ const conflict = await mcpCall<{
1296
+ conflicts: Array<{
1297
+ path: string;
1298
+ holders: Array<{ agent: string; path_pattern: string }>;
1299
+ }>;
1300
+ }>("file_reservation_paths", {
1301
+ project_key: worker1.projectKey,
1302
+ agent_name: worker1.agentName,
1303
+ paths: [path2],
1304
+ exclusive: true,
1305
+ });
1306
+
1307
+ expect(conflict.conflicts.length).toBe(1);
1308
+ // holders is an array of objects with agent field
1309
+ expect(
1310
+ conflict.conflicts[0].holders.some(
1311
+ (h) => h.agent === worker2.agentName,
1312
+ ),
1313
+ ).toBe(true);
1314
+
1315
+ // Cleanup
1316
+ clearState(coordCtx.sessionID);
1317
+ clearState(worker1Ctx.sessionID);
1318
+ clearState(worker2Ctx.sessionID);
1319
+ });
1320
+ });
1321
+ });