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.
- package/README.md +201 -0
- package/package.json +28 -0
- package/src/adapter.ts +306 -0
- package/src/index.ts +57 -0
- package/src/pglite.ts +189 -0
- package/src/streams/agent-mail.test.ts +777 -0
- package/src/streams/agent-mail.ts +535 -0
- package/src/streams/debug.test.ts +500 -0
- package/src/streams/debug.ts +727 -0
- package/src/streams/effect/ask.integration.test.ts +314 -0
- package/src/streams/effect/ask.ts +202 -0
- package/src/streams/effect/cursor.integration.test.ts +418 -0
- package/src/streams/effect/cursor.ts +288 -0
- package/src/streams/effect/deferred.test.ts +357 -0
- package/src/streams/effect/deferred.ts +445 -0
- package/src/streams/effect/index.ts +17 -0
- package/src/streams/effect/layers.ts +73 -0
- package/src/streams/effect/lock.test.ts +385 -0
- package/src/streams/effect/lock.ts +399 -0
- package/src/streams/effect/mailbox.test.ts +260 -0
- package/src/streams/effect/mailbox.ts +318 -0
- package/src/streams/events.test.ts +924 -0
- package/src/streams/events.ts +329 -0
- package/src/streams/index.test.ts +229 -0
- package/src/streams/index.ts +578 -0
- package/src/streams/migrations.test.ts +359 -0
- package/src/streams/migrations.ts +362 -0
- package/src/streams/projections.test.ts +611 -0
- package/src/streams/projections.ts +564 -0
- package/src/streams/store.integration.test.ts +658 -0
- package/src/streams/store.ts +1129 -0
- package/src/streams/swarm-mail.ts +552 -0
- package/src/types/adapter.ts +392 -0
- package/src/types/database.ts +127 -0
- package/src/types/index.ts +26 -0
- 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
|
+
});
|