@desplega.ai/agent-swarm 1.52.1 → 1.53.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 (43) hide show
  1. package/openapi.json +1517 -488
  2. package/package.json +5 -2
  3. package/src/be/db.ts +530 -0
  4. package/src/be/events.ts +322 -0
  5. package/src/be/migrations/021_events.sql +24 -0
  6. package/src/be/migrations/022_context_usage.sql +34 -0
  7. package/src/be/migrations/023_mcp_servers.sql +44 -0
  8. package/src/commands/runner.ts +348 -1
  9. package/src/http/context.ts +118 -0
  10. package/src/http/events.ts +188 -0
  11. package/src/http/index.ts +6 -0
  12. package/src/http/mcp-servers.ts +364 -0
  13. package/src/http/tasks.ts +33 -0
  14. package/src/linear/outbound.ts +8 -1
  15. package/src/linear/sync.ts +3 -0
  16. package/src/oauth/ensure-token.ts +50 -0
  17. package/src/prompts/base-prompt.ts +7 -0
  18. package/src/providers/claude-adapter.ts +156 -15
  19. package/src/providers/pi-mono-adapter.ts +68 -0
  20. package/src/providers/pi-mono-extension.ts +56 -2
  21. package/src/providers/pi-mono-mcp-client.ts +10 -1
  22. package/src/providers/types.ts +14 -1
  23. package/src/server.ts +19 -0
  24. package/src/tests/context-window.test.ts +66 -0
  25. package/src/tests/ensure-token.test.ts +170 -0
  26. package/src/tests/events-db.test.ts +314 -0
  27. package/src/tests/events-http.test.ts +267 -0
  28. package/src/tests/prompt-template-remaining.test.ts +5 -5
  29. package/src/tests/tool-annotations.test.ts +2 -2
  30. package/src/tests/vcs-tracking.test.ts +176 -0
  31. package/src/tests/workflow-executors.test.ts +8 -1
  32. package/src/tools/mcp-servers/index.ts +7 -0
  33. package/src/tools/mcp-servers/mcp-server-create.ts +138 -0
  34. package/src/tools/mcp-servers/mcp-server-delete.ts +72 -0
  35. package/src/tools/mcp-servers/mcp-server-get.ts +80 -0
  36. package/src/tools/mcp-servers/mcp-server-install.ts +110 -0
  37. package/src/tools/mcp-servers/mcp-server-list.ts +67 -0
  38. package/src/tools/mcp-servers/mcp-server-uninstall.ts +71 -0
  39. package/src/tools/mcp-servers/mcp-server-update.ts +120 -0
  40. package/src/tools/tool-config.ts +9 -0
  41. package/src/types.ts +153 -0
  42. package/src/utils/context-window.ts +41 -0
  43. package/src/workflows/executors/base.ts +9 -1
@@ -0,0 +1,170 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb, initDb } from "../be/db";
4
+ import {
5
+ deleteOAuthTokens,
6
+ getOAuthTokens,
7
+ storeOAuthTokens,
8
+ upsertOAuthApp,
9
+ } from "../be/db-queries/oauth";
10
+ import { ensureToken } from "../oauth/ensure-token";
11
+
12
+ const TEST_DB_PATH = "./test-ensure-token.sqlite";
13
+
14
+ const testApp = {
15
+ clientId: "test-client-id",
16
+ clientSecret: "test-client-secret",
17
+ authorizeUrl: "https://example.com/oauth/authorize",
18
+ tokenUrl: "https://example.com/oauth/token",
19
+ redirectUri: "http://localhost:3013/callback",
20
+ scopes: "read,write",
21
+ };
22
+
23
+ const originalFetch = globalThis.fetch;
24
+
25
+ beforeAll(() => {
26
+ initDb(TEST_DB_PATH);
27
+ upsertOAuthApp("test-provider", testApp);
28
+ });
29
+
30
+ beforeEach(() => {
31
+ deleteOAuthTokens("test-provider");
32
+ globalThis.fetch = originalFetch;
33
+ });
34
+
35
+ afterAll(async () => {
36
+ globalThis.fetch = originalFetch;
37
+ closeDb();
38
+ await unlink(TEST_DB_PATH).catch(() => {});
39
+ await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
40
+ await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
41
+ });
42
+
43
+ describe("ensureToken", () => {
44
+ test("does nothing when token is not expiring", async () => {
45
+ storeOAuthTokens("test-provider", {
46
+ accessToken: "valid-token",
47
+ refreshToken: "refresh-token",
48
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour from now
49
+ });
50
+
51
+ const fetchSpy = mock(() => Promise.resolve(new Response()));
52
+ globalThis.fetch = fetchSpy;
53
+
54
+ await ensureToken("test-provider");
55
+
56
+ // No fetch call should have been made — token is still valid
57
+ expect(fetchSpy).not.toHaveBeenCalled();
58
+
59
+ // Token should be unchanged
60
+ const tokens = getOAuthTokens("test-provider");
61
+ expect(tokens?.accessToken).toBe("valid-token");
62
+ });
63
+
64
+ test("refreshes token when expiring soon", async () => {
65
+ storeOAuthTokens("test-provider", {
66
+ accessToken: "old-token",
67
+ refreshToken: "refresh-token",
68
+ expiresAt: new Date(Date.now() + 2 * 60 * 1000).toISOString(), // 2 minutes (within 5-min buffer)
69
+ });
70
+
71
+ const fetchSpy = mock(() =>
72
+ Promise.resolve(
73
+ new Response(
74
+ JSON.stringify({
75
+ access_token: "new-access-token",
76
+ token_type: "Bearer",
77
+ expires_in: 3600,
78
+ refresh_token: "new-refresh-token",
79
+ }),
80
+ { status: 200, headers: { "Content-Type": "application/json" } },
81
+ ),
82
+ ),
83
+ );
84
+ globalThis.fetch = fetchSpy;
85
+
86
+ await ensureToken("test-provider");
87
+
88
+ // Should have called the token endpoint
89
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
90
+ const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
91
+ expect(url).toBe("https://example.com/oauth/token");
92
+ expect(init.method).toBe("POST");
93
+ expect(init.body).toContain("grant_type=refresh_token");
94
+ expect(init.body).toContain("refresh_token=refresh-token");
95
+
96
+ // Token should be updated in DB
97
+ const tokens = getOAuthTokens("test-provider");
98
+ expect(tokens?.accessToken).toBe("new-access-token");
99
+ expect(tokens?.refreshToken).toBe("new-refresh-token");
100
+ });
101
+
102
+ test("handles gracefully when no tokens exist", async () => {
103
+ // No tokens stored — isTokenExpiringSoon returns true but no refresh token available
104
+ deleteOAuthTokens("test-provider");
105
+
106
+ const fetchSpy = mock(() => Promise.resolve(new Response()));
107
+ globalThis.fetch = fetchSpy;
108
+
109
+ // Should not throw
110
+ await ensureToken("test-provider");
111
+
112
+ // No fetch call — can't refresh without a refresh token
113
+ expect(fetchSpy).not.toHaveBeenCalled();
114
+ });
115
+
116
+ test("handles gracefully when no OAuth app is configured", async () => {
117
+ // Store expiring token for the configured provider, but query a nonexistent one
118
+ const fetchSpy = mock(() => Promise.resolve(new Response()));
119
+ globalThis.fetch = fetchSpy;
120
+
121
+ // Should not throw for unconfigured provider
122
+ await ensureToken("nonexistent-provider");
123
+
124
+ expect(fetchSpy).not.toHaveBeenCalled();
125
+ });
126
+
127
+ test("handles refresh failure gracefully", async () => {
128
+ storeOAuthTokens("test-provider", {
129
+ accessToken: "old-token",
130
+ refreshToken: "refresh-token",
131
+ expiresAt: new Date(Date.now() + 60 * 1000).toISOString(), // 1 minute from now
132
+ });
133
+
134
+ const fetchSpy = mock(() =>
135
+ Promise.resolve(
136
+ new Response('{"error":"invalid_grant"}', {
137
+ status: 400,
138
+ headers: { "Content-Type": "application/json" },
139
+ }),
140
+ ),
141
+ );
142
+ globalThis.fetch = fetchSpy;
143
+
144
+ // Should not throw — error is caught and logged
145
+ await ensureToken("test-provider");
146
+
147
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
148
+
149
+ // Original token should still be in DB (refresh failed)
150
+ const tokens = getOAuthTokens("test-provider");
151
+ expect(tokens?.accessToken).toBe("old-token");
152
+ });
153
+
154
+ test("handles token with no refresh token", async () => {
155
+ storeOAuthTokens("test-provider", {
156
+ accessToken: "old-token",
157
+ refreshToken: null,
158
+ expiresAt: new Date(Date.now() + 60 * 1000).toISOString(), // 1 minute from now
159
+ });
160
+
161
+ const fetchSpy = mock(() => Promise.resolve(new Response()));
162
+ globalThis.fetch = fetchSpy;
163
+
164
+ // Should not throw
165
+ await ensureToken("test-provider");
166
+
167
+ // No fetch — can't refresh without a refresh token
168
+ expect(fetchSpy).not.toHaveBeenCalled();
169
+ });
170
+ });
@@ -0,0 +1,314 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb, createAgent, initDb } from "../be/db";
4
+ import {
5
+ createEvent,
6
+ createEventsBatch,
7
+ getAllEvents,
8
+ getEventCounts,
9
+ getEventCountsFiltered,
10
+ getEventCountsForAgent,
11
+ getEventsByAgentId,
12
+ getEventsByCategory,
13
+ getEventsByEvent,
14
+ getEventsBySessionId,
15
+ getEventsByTaskId,
16
+ getEventsFiltered,
17
+ } from "../be/events";
18
+ import type { SwarmEvent } from "../types";
19
+
20
+ const TEST_DB_PATH = "./test-events-db.sqlite";
21
+
22
+ let testAgent: { id: string };
23
+
24
+ beforeAll(async () => {
25
+ try {
26
+ await unlink(TEST_DB_PATH);
27
+ } catch {}
28
+ initDb(TEST_DB_PATH);
29
+ testAgent = createAgent({ name: "Events Test Agent", isLead: false, status: "idle" });
30
+ });
31
+
32
+ afterAll(async () => {
33
+ closeDb();
34
+ try {
35
+ await unlink(TEST_DB_PATH);
36
+ await unlink(`${TEST_DB_PATH}-wal`);
37
+ await unlink(`${TEST_DB_PATH}-shm`);
38
+ } catch {}
39
+ });
40
+
41
+ describe("createEvent", () => {
42
+ test("creates a single event with required fields", () => {
43
+ const evt = createEvent({
44
+ category: "tool",
45
+ event: "tool.start",
46
+ source: "worker",
47
+ });
48
+ expect(evt.id).toBeDefined();
49
+ expect(evt.category).toBe("tool");
50
+ expect(evt.event).toBe("tool.start");
51
+ expect(evt.status).toBe("ok");
52
+ expect(evt.source).toBe("worker");
53
+ expect(evt.createdAt).toBeDefined();
54
+ });
55
+
56
+ test("creates event with all optional fields", () => {
57
+ const evt = createEvent({
58
+ category: "tool",
59
+ event: "tool.start",
60
+ source: "worker",
61
+ status: "error",
62
+ agentId: testAgent.id,
63
+ taskId: "task-123",
64
+ sessionId: "session-456",
65
+ parentEventId: "parent-789",
66
+ numericValue: 42.5,
67
+ durationMs: 1500,
68
+ data: { toolName: "Read", filePath: "/tmp/test.txt" },
69
+ });
70
+ expect(evt.status).toBe("error");
71
+ expect(evt.agentId).toBe(testAgent.id);
72
+ expect(evt.taskId).toBe("task-123");
73
+ expect(evt.sessionId).toBe("session-456");
74
+ expect(evt.parentEventId).toBe("parent-789");
75
+ expect(evt.numericValue).toBe(42.5);
76
+ expect(evt.durationMs).toBe(1500);
77
+ expect(evt.data).toEqual({ toolName: "Read", filePath: "/tmp/test.txt" });
78
+ });
79
+
80
+ test("defaults status to ok", () => {
81
+ const evt = createEvent({ category: "system", event: "system.boot", source: "api" });
82
+ expect(evt.status).toBe("ok");
83
+ });
84
+ });
85
+
86
+ describe("createEventsBatch", () => {
87
+ test("inserts multiple events in a transaction", () => {
88
+ const before = getAllEvents(1000).length;
89
+ const count = createEventsBatch([
90
+ { category: "tool", event: "tool.start", source: "worker", agentId: testAgent.id },
91
+ { category: "tool", event: "tool.end", source: "worker", agentId: testAgent.id },
92
+ { category: "skill", event: "skill.invoke", source: "worker", agentId: testAgent.id },
93
+ ]);
94
+ expect(count).toBe(3);
95
+ const after = getAllEvents(1000).length;
96
+ expect(after - before).toBe(3);
97
+ });
98
+
99
+ test("returns 0 for empty batch", () => {
100
+ const count = createEventsBatch([]);
101
+ expect(count).toBe(0);
102
+ });
103
+ });
104
+
105
+ describe("query functions", () => {
106
+ const sessionId = `query-test-session-${Date.now()}`;
107
+ const taskId = `query-test-task-${Date.now()}`;
108
+
109
+ beforeAll(() => {
110
+ // Seed events for query tests
111
+ createEventsBatch([
112
+ {
113
+ category: "tool",
114
+ event: "tool.start",
115
+ source: "worker",
116
+ agentId: testAgent.id,
117
+ sessionId,
118
+ taskId,
119
+ data: { toolName: "Read" },
120
+ },
121
+ {
122
+ category: "tool",
123
+ event: "tool.start",
124
+ source: "worker",
125
+ agentId: testAgent.id,
126
+ sessionId,
127
+ taskId,
128
+ data: { toolName: "Bash" },
129
+ },
130
+ {
131
+ category: "skill",
132
+ event: "skill.invoke",
133
+ source: "worker",
134
+ agentId: testAgent.id,
135
+ sessionId,
136
+ taskId,
137
+ data: { skillName: "commit" },
138
+ },
139
+ {
140
+ category: "session",
141
+ event: "session.start",
142
+ source: "worker",
143
+ agentId: testAgent.id,
144
+ sessionId,
145
+ taskId,
146
+ },
147
+ {
148
+ category: "api",
149
+ event: "api.request",
150
+ source: "api",
151
+ data: { method: "GET", path: "/health" },
152
+ },
153
+ ]);
154
+ });
155
+
156
+ test("getEventsByCategory filters by category", () => {
157
+ const tools = getEventsByCategory("tool");
158
+ expect(tools.length).toBeGreaterThanOrEqual(2);
159
+ for (const evt of tools) {
160
+ expect(evt.category).toBe("tool");
161
+ }
162
+ });
163
+
164
+ test("getEventsByEvent filters by event name", () => {
165
+ const starts = getEventsByEvent("tool.start");
166
+ expect(starts.length).toBeGreaterThanOrEqual(2);
167
+ for (const evt of starts) {
168
+ expect(evt.event).toBe("tool.start");
169
+ }
170
+ });
171
+
172
+ test("getEventsByAgentId filters by agentId", () => {
173
+ const agentEvents = getEventsByAgentId(testAgent.id);
174
+ expect(agentEvents.length).toBeGreaterThanOrEqual(4);
175
+ for (const evt of agentEvents) {
176
+ expect(evt.agentId).toBe(testAgent.id);
177
+ }
178
+ });
179
+
180
+ test("getEventsByTaskId filters by taskId", () => {
181
+ const taskEvents = getEventsByTaskId(taskId);
182
+ expect(taskEvents.length).toBeGreaterThanOrEqual(4);
183
+ for (const evt of taskEvents) {
184
+ expect(evt.taskId).toBe(taskId);
185
+ }
186
+ });
187
+
188
+ test("getEventsBySessionId filters by sessionId", () => {
189
+ const sessionEvents = getEventsBySessionId(sessionId);
190
+ expect(sessionEvents.length).toBeGreaterThanOrEqual(4);
191
+ for (const evt of sessionEvents) {
192
+ expect(evt.sessionId).toBe(sessionId);
193
+ }
194
+ });
195
+
196
+ test("getAllEvents respects limit", () => {
197
+ const events = getAllEvents(2);
198
+ expect(events.length).toBe(2);
199
+ });
200
+
201
+ test("getAllEvents returns descending createdAt order", () => {
202
+ const events = getAllEvents(10);
203
+ for (let i = 1; i < events.length; i++) {
204
+ expect(events[i - 1].createdAt >= events[i].createdAt).toBe(true);
205
+ }
206
+ });
207
+
208
+ test("getEventCounts groups by event name", () => {
209
+ const counts = getEventCounts();
210
+ expect(counts.length).toBeGreaterThanOrEqual(1);
211
+ const toolStart = counts.find((c) => c.event === "tool.start");
212
+ expect(toolStart).toBeDefined();
213
+ expect(toolStart!.count).toBeGreaterThanOrEqual(2);
214
+ });
215
+
216
+ test("getEventCountsForAgent scopes to agent", () => {
217
+ const counts = getEventCountsForAgent(testAgent.id);
218
+ expect(counts.length).toBeGreaterThanOrEqual(1);
219
+ // All counted events should belong to this agent
220
+ const total = counts.reduce((sum, c) => sum + c.count, 0);
221
+ const agentEvents = getEventsByAgentId(testAgent.id);
222
+ expect(total).toBe(agentEvents.length);
223
+ });
224
+ });
225
+
226
+ describe("getEventsFiltered", () => {
227
+ test("filters by multiple criteria", () => {
228
+ const events = getEventsFiltered({
229
+ category: "tool",
230
+ source: "worker",
231
+ agentId: testAgent.id,
232
+ limit: 5,
233
+ });
234
+ for (const evt of events) {
235
+ expect(evt.category).toBe("tool");
236
+ expect(evt.source).toBe("worker");
237
+ expect(evt.agentId).toBe(testAgent.id);
238
+ }
239
+ expect(events.length).toBeLessThanOrEqual(5);
240
+ });
241
+
242
+ test("filters by status", () => {
243
+ createEvent({
244
+ category: "api",
245
+ event: "api.error",
246
+ source: "api",
247
+ status: "error",
248
+ data: { message: "timeout" },
249
+ });
250
+ const errors = getEventsFiltered({ status: "error" });
251
+ expect(errors.length).toBeGreaterThanOrEqual(1);
252
+ for (const evt of errors) {
253
+ expect(evt.status).toBe("error");
254
+ }
255
+ });
256
+
257
+ test("defaults limit to 100", () => {
258
+ const events = getEventsFiltered({});
259
+ expect(events.length).toBeLessThanOrEqual(100);
260
+ });
261
+ });
262
+
263
+ describe("getEventCountsFiltered", () => {
264
+ test("filters counts by category", () => {
265
+ const counts = getEventCountsFiltered({ category: "tool" });
266
+ for (const c of counts) {
267
+ // Each counted event name should be a tool event
268
+ expect(c.event.startsWith("tool.")).toBe(true);
269
+ }
270
+ });
271
+
272
+ test("filters counts by source", () => {
273
+ const counts = getEventCountsFiltered({ source: "api" });
274
+ expect(counts.length).toBeGreaterThanOrEqual(1);
275
+ });
276
+
277
+ test("returns empty for non-matching filters", () => {
278
+ const counts = getEventCountsFiltered({ agentId: "nonexistent-agent-id" });
279
+ expect(counts.length).toBe(0);
280
+ });
281
+ });
282
+
283
+ describe("data JSON round-trip", () => {
284
+ test("preserves nested objects in data field", () => {
285
+ const data = {
286
+ toolName: "Read",
287
+ nested: { deep: { value: 42 } },
288
+ array: [1, "two", { three: true }],
289
+ };
290
+ const evt = createEvent({
291
+ category: "tool",
292
+ event: "tool.start",
293
+ source: "worker",
294
+ data,
295
+ });
296
+ // Re-fetch from DB to verify round-trip
297
+ const fetched = getEventsFiltered({ limit: 1 });
298
+ const found = fetched.find((e) => e.id === evt.id);
299
+ expect(found).toBeDefined();
300
+ expect(found!.data).toEqual(data);
301
+ });
302
+
303
+ test("handles null data gracefully", () => {
304
+ const evt = createEvent({
305
+ category: "system",
306
+ event: "system.boot",
307
+ source: "api",
308
+ });
309
+ const fetched = getEventsFiltered({ limit: 1000 });
310
+ const found = fetched.find((e) => e.id === evt.id);
311
+ expect(found).toBeDefined();
312
+ expect(found!.data).toBeUndefined();
313
+ });
314
+ });