@desplega.ai/agent-swarm 1.9.0 → 1.10.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,456 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { createServer as createHttpServer, type Server } from "node:http";
4
+ import {
5
+ closeDb,
6
+ createAgent,
7
+ createTaskExtended,
8
+ getAgentById,
9
+ getDb,
10
+ getInboxSummary,
11
+ getOfferedTasksForAgent,
12
+ getPendingTaskForAgent,
13
+ getUnassignedTasksCount,
14
+ initDb,
15
+ updateAgentStatus,
16
+ } from "../be/db";
17
+
18
+ const TEST_DB_PATH = "./test-runner-polling.sqlite";
19
+ const TEST_PORT = 13013;
20
+
21
+ // Helper to parse path segments
22
+ function getPathSegments(url: string): string[] {
23
+ const pathEnd = url.indexOf("?");
24
+ const path = pathEnd === -1 ? url : url.slice(0, pathEnd);
25
+ return path.split("/").filter(Boolean);
26
+ }
27
+
28
+ // Minimal HTTP handler for the endpoints we're testing
29
+ async function handleRequest(
30
+ req: { method: string; url: string; headers: { get: (key: string) => string | null } },
31
+ body: string,
32
+ ): Promise<{ status: number; body: unknown }> {
33
+ const pathSegments = getPathSegments(req.url || "");
34
+ const myAgentId = req.headers.get("x-agent-id");
35
+
36
+ // POST /api/agents - Register a new agent
37
+ if (
38
+ req.method === "POST" &&
39
+ pathSegments[0] === "api" &&
40
+ pathSegments[1] === "agents" &&
41
+ !pathSegments[2]
42
+ ) {
43
+ const parsedBody = JSON.parse(body);
44
+
45
+ if (!parsedBody.name || typeof parsedBody.name !== "string") {
46
+ return { status: 400, body: { error: "Missing or invalid 'name' field" } };
47
+ }
48
+
49
+ const agentId = myAgentId || crypto.randomUUID();
50
+
51
+ const result = getDb().transaction(() => {
52
+ const existingAgent = getAgentById(agentId);
53
+ if (existingAgent) {
54
+ if (existingAgent.status === "offline") {
55
+ updateAgentStatus(existingAgent.id, "idle");
56
+ }
57
+ return { agent: getAgentById(agentId), created: false };
58
+ }
59
+
60
+ const agent = createAgent({
61
+ id: agentId,
62
+ name: parsedBody.name,
63
+ isLead: parsedBody.isLead ?? false,
64
+ status: "idle",
65
+ description: parsedBody.description,
66
+ role: parsedBody.role,
67
+ capabilities: parsedBody.capabilities,
68
+ });
69
+
70
+ return { agent, created: true };
71
+ })();
72
+
73
+ return { status: result.created ? 201 : 200, body: result.agent };
74
+ }
75
+
76
+ // GET /api/poll - Poll for triggers
77
+ if (req.method === "GET" && pathSegments[0] === "api" && pathSegments[1] === "poll") {
78
+ if (!myAgentId) {
79
+ return { status: 400, body: { error: "Missing X-Agent-ID header" } };
80
+ }
81
+
82
+ const result = getDb().transaction(() => {
83
+ const agent = getAgentById(myAgentId);
84
+ if (!agent) {
85
+ return { error: "Agent not found", status: 404 };
86
+ }
87
+
88
+ const offeredTasks = getOfferedTasksForAgent(myAgentId);
89
+ const firstOfferedTask = offeredTasks[0];
90
+ if (firstOfferedTask) {
91
+ return {
92
+ trigger: {
93
+ type: "task_offered",
94
+ taskId: firstOfferedTask.id,
95
+ task: firstOfferedTask,
96
+ },
97
+ };
98
+ }
99
+
100
+ const pendingTask = getPendingTaskForAgent(myAgentId);
101
+ if (pendingTask) {
102
+ return {
103
+ trigger: {
104
+ type: "task_assigned",
105
+ taskId: pendingTask.id,
106
+ task: pendingTask,
107
+ },
108
+ };
109
+ }
110
+
111
+ if (agent.isLead) {
112
+ const inbox = getInboxSummary(myAgentId);
113
+ if (inbox.mentionsCount > 0) {
114
+ return {
115
+ trigger: {
116
+ type: "unread_mentions",
117
+ mentionsCount: inbox.mentionsCount,
118
+ },
119
+ };
120
+ }
121
+
122
+ const unassignedCount = getUnassignedTasksCount();
123
+ if (unassignedCount > 0) {
124
+ return {
125
+ trigger: {
126
+ type: "pool_tasks_available",
127
+ count: unassignedCount,
128
+ },
129
+ };
130
+ }
131
+ }
132
+
133
+ return { trigger: null };
134
+ })();
135
+
136
+ if ("error" in result) {
137
+ return { status: result.status ?? 500, body: { error: result.error } };
138
+ }
139
+
140
+ return { status: 200, body: result };
141
+ }
142
+
143
+ return { status: 404, body: { error: "Not found" } };
144
+ }
145
+
146
+ // Create test HTTP server
147
+ function createTestServer(): Server {
148
+ return createHttpServer(async (req, res) => {
149
+ res.setHeader("Content-Type", "application/json");
150
+
151
+ const chunks: Buffer[] = [];
152
+ for await (const chunk of req) {
153
+ chunks.push(chunk);
154
+ }
155
+ const body = Buffer.concat(chunks).toString();
156
+
157
+ const headers = {
158
+ get: (key: string) => req.headers[key.toLowerCase()] as string | null,
159
+ };
160
+
161
+ const result = await handleRequest(
162
+ { method: req.method || "GET", url: req.url || "/", headers },
163
+ body,
164
+ );
165
+
166
+ res.writeHead(result.status);
167
+ res.end(JSON.stringify(result.body));
168
+ });
169
+ }
170
+
171
+ describe("Runner-Level Polling API", () => {
172
+ let server: Server;
173
+ const baseUrl = `http://localhost:${TEST_PORT}`;
174
+
175
+ beforeAll(async () => {
176
+ // Clean up any existing test database
177
+ try {
178
+ await unlink(TEST_DB_PATH);
179
+ } catch {
180
+ // File doesn't exist, that's fine
181
+ }
182
+
183
+ // Initialize test database
184
+ initDb(TEST_DB_PATH);
185
+
186
+ // Start test server
187
+ server = createTestServer();
188
+ await new Promise<void>((resolve) => {
189
+ server.listen(TEST_PORT, () => {
190
+ console.log(`Test server listening on port ${TEST_PORT}`);
191
+ resolve();
192
+ });
193
+ });
194
+ });
195
+
196
+ afterAll(async () => {
197
+ // Close server
198
+ await new Promise<void>((resolve) => {
199
+ server.close(() => resolve());
200
+ });
201
+
202
+ // Close database
203
+ closeDb();
204
+
205
+ // Clean up test database file
206
+ try {
207
+ await unlink(TEST_DB_PATH);
208
+ await unlink(`${TEST_DB_PATH}-wal`);
209
+ await unlink(`${TEST_DB_PATH}-shm`);
210
+ } catch {
211
+ // Files may not exist
212
+ }
213
+ });
214
+
215
+ describe("POST /api/agents", () => {
216
+ test("should create a new agent with provided ID", async () => {
217
+ const agentId = "test-agent-001";
218
+ const response = await fetch(`${baseUrl}/api/agents`, {
219
+ method: "POST",
220
+ headers: {
221
+ "Content-Type": "application/json",
222
+ "X-Agent-ID": agentId,
223
+ },
224
+ body: JSON.stringify({ name: "Test Agent 1" }),
225
+ });
226
+
227
+ expect(response.status).toBe(201);
228
+ const data = await response.json();
229
+ expect(data.id).toBe(agentId);
230
+ expect(data.name).toBe("Test Agent 1");
231
+ expect(data.status).toBe("idle");
232
+ });
233
+
234
+ test("should return existing agent if already registered", async () => {
235
+ const agentId = "test-agent-001";
236
+ const response = await fetch(`${baseUrl}/api/agents`, {
237
+ method: "POST",
238
+ headers: {
239
+ "Content-Type": "application/json",
240
+ "X-Agent-ID": agentId,
241
+ },
242
+ body: JSON.stringify({ name: "Test Agent 1 Updated" }),
243
+ });
244
+
245
+ expect(response.status).toBe(200); // Not 201 since it exists
246
+ const data = await response.json();
247
+ expect(data.id).toBe(agentId);
248
+ expect(data.name).toBe("Test Agent 1"); // Original name preserved
249
+ });
250
+
251
+ test("should generate UUID if no X-Agent-ID header", async () => {
252
+ const response = await fetch(`${baseUrl}/api/agents`, {
253
+ method: "POST",
254
+ headers: {
255
+ "Content-Type": "application/json",
256
+ },
257
+ body: JSON.stringify({ name: "Auto-ID Agent" }),
258
+ });
259
+
260
+ expect(response.status).toBe(201);
261
+ const data = await response.json();
262
+ expect(data.id).toBeDefined();
263
+ expect(data.id.length).toBe(36); // UUID format
264
+ expect(data.name).toBe("Auto-ID Agent");
265
+ });
266
+
267
+ test("should return 400 if name is missing", async () => {
268
+ const response = await fetch(`${baseUrl}/api/agents`, {
269
+ method: "POST",
270
+ headers: {
271
+ "Content-Type": "application/json",
272
+ "X-Agent-ID": "test-agent-bad",
273
+ },
274
+ body: JSON.stringify({}),
275
+ });
276
+
277
+ expect(response.status).toBe(400);
278
+ const data = await response.json();
279
+ expect(data.error).toContain("name");
280
+ });
281
+
282
+ test("should create lead agent with isLead flag", async () => {
283
+ const agentId = "test-lead-001";
284
+ const response = await fetch(`${baseUrl}/api/agents`, {
285
+ method: "POST",
286
+ headers: {
287
+ "Content-Type": "application/json",
288
+ "X-Agent-ID": agentId,
289
+ },
290
+ body: JSON.stringify({ name: "Lead Agent", isLead: true }),
291
+ });
292
+
293
+ expect(response.status).toBe(201);
294
+ const data = await response.json();
295
+ expect(data.id).toBe(agentId);
296
+ expect(data.isLead).toBe(true);
297
+ });
298
+ });
299
+
300
+ describe("GET /api/poll", () => {
301
+ test("should return 400 if X-Agent-ID header is missing", async () => {
302
+ const response = await fetch(`${baseUrl}/api/poll`);
303
+
304
+ expect(response.status).toBe(400);
305
+ const data = await response.json();
306
+ expect(data.error).toContain("X-Agent-ID");
307
+ });
308
+
309
+ test("should return 404 if agent does not exist", async () => {
310
+ const response = await fetch(`${baseUrl}/api/poll`, {
311
+ headers: {
312
+ "X-Agent-ID": "non-existent-agent",
313
+ },
314
+ });
315
+
316
+ expect(response.status).toBe(404);
317
+ const data = await response.json();
318
+ expect(data.error).toContain("not found");
319
+ });
320
+
321
+ test("should return null trigger when no work available", async () => {
322
+ const response = await fetch(`${baseUrl}/api/poll`, {
323
+ headers: {
324
+ "X-Agent-ID": "test-agent-001",
325
+ },
326
+ });
327
+
328
+ expect(response.status).toBe(200);
329
+ const data = await response.json();
330
+ expect(data.trigger).toBeNull();
331
+ });
332
+
333
+ test("should return task_assigned trigger when pending task exists", async () => {
334
+ const agentId = "test-worker-with-task";
335
+
336
+ // Create agent first
337
+ await fetch(`${baseUrl}/api/agents`, {
338
+ method: "POST",
339
+ headers: {
340
+ "Content-Type": "application/json",
341
+ "X-Agent-ID": agentId,
342
+ },
343
+ body: JSON.stringify({ name: "Worker With Task" }),
344
+ });
345
+
346
+ // Create a pending task assigned to this agent
347
+ const task = createTaskExtended("Test task for worker", {
348
+ agentId,
349
+ creatorAgentId: "test-lead-001",
350
+ });
351
+
352
+ const response = await fetch(`${baseUrl}/api/poll`, {
353
+ headers: {
354
+ "X-Agent-ID": agentId,
355
+ },
356
+ });
357
+
358
+ expect(response.status).toBe(200);
359
+ const data = await response.json();
360
+ expect(data.trigger).not.toBeNull();
361
+ expect(data.trigger.type).toBe("task_assigned");
362
+ expect(data.trigger.taskId).toBe(task.id);
363
+ });
364
+
365
+ test("should return task_offered trigger when offered task exists", async () => {
366
+ const agentId = "test-worker-with-offer";
367
+
368
+ // Create agent first
369
+ await fetch(`${baseUrl}/api/agents`, {
370
+ method: "POST",
371
+ headers: {
372
+ "Content-Type": "application/json",
373
+ "X-Agent-ID": agentId,
374
+ },
375
+ body: JSON.stringify({ name: "Worker With Offer" }),
376
+ });
377
+
378
+ // Create an offered task for this agent (using offeredTo sets status to "offered")
379
+ const task = createTaskExtended("Offered task for worker", {
380
+ offeredTo: agentId,
381
+ creatorAgentId: "test-lead-001",
382
+ });
383
+
384
+ const response = await fetch(`${baseUrl}/api/poll`, {
385
+ headers: {
386
+ "X-Agent-ID": agentId,
387
+ },
388
+ });
389
+
390
+ expect(response.status).toBe(200);
391
+ const data = await response.json();
392
+ expect(data.trigger).not.toBeNull();
393
+ expect(data.trigger.type).toBe("task_offered");
394
+ expect(data.trigger.taskId).toBe(task.id);
395
+ });
396
+
397
+ test("should return pool_tasks_available for lead when unassigned tasks exist", async () => {
398
+ const leadId = "test-lead-poll";
399
+
400
+ // Create lead agent
401
+ await fetch(`${baseUrl}/api/agents`, {
402
+ method: "POST",
403
+ headers: {
404
+ "Content-Type": "application/json",
405
+ "X-Agent-ID": leadId,
406
+ },
407
+ body: JSON.stringify({ name: "Lead For Poll Test", isLead: true }),
408
+ });
409
+
410
+ // Create an unassigned task (no agentId means status = "unassigned")
411
+ createTaskExtended("Unassigned task in pool", {
412
+ creatorAgentId: leadId,
413
+ // No agentId = unassigned
414
+ });
415
+
416
+ const response = await fetch(`${baseUrl}/api/poll`, {
417
+ headers: {
418
+ "X-Agent-ID": leadId,
419
+ },
420
+ });
421
+
422
+ expect(response.status).toBe(200);
423
+ const data = await response.json();
424
+ expect(data.trigger).not.toBeNull();
425
+ expect(data.trigger.type).toBe("pool_tasks_available");
426
+ expect(data.trigger.count).toBeGreaterThan(0);
427
+ });
428
+
429
+ test("worker should NOT see pool_tasks_available trigger", async () => {
430
+ const workerId = "test-worker-no-pool";
431
+
432
+ // Create worker agent (not lead)
433
+ await fetch(`${baseUrl}/api/agents`, {
434
+ method: "POST",
435
+ headers: {
436
+ "Content-Type": "application/json",
437
+ "X-Agent-ID": workerId,
438
+ },
439
+ body: JSON.stringify({ name: "Worker No Pool Access", isLead: false }),
440
+ });
441
+
442
+ // There's already an unassigned task from previous test
443
+
444
+ const response = await fetch(`${baseUrl}/api/poll`, {
445
+ headers: {
446
+ "X-Agent-ID": workerId,
447
+ },
448
+ });
449
+
450
+ expect(response.status).toBe(200);
451
+ const data = await response.json();
452
+ // Worker should NOT see pool tasks
453
+ expect(data.trigger).toBeNull();
454
+ });
455
+ });
456
+ });