@desplega.ai/agent-swarm 1.84.1 → 1.85.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.
@@ -2,10 +2,12 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import { closeDb, initDb } from "../be/db";
4
4
  import {
5
+ acquireOAuthRefreshLock,
5
6
  deleteOAuthTokens,
6
7
  getOAuthApp,
7
8
  getOAuthTokens,
8
9
  isTokenExpiringSoon,
10
+ releaseOAuthRefreshLock,
9
11
  storeOAuthTokens,
10
12
  updateOAuthTokensAfterRefresh,
11
13
  upsertOAuthApp,
@@ -238,3 +240,28 @@ describe("isTokenExpiringSoon", () => {
238
240
  expect(isTokenExpiringSoon("expiry-test", 180000)).toBe(true);
239
241
  });
240
242
  });
243
+
244
+ describe("OAuth refresh locks", () => {
245
+ test("allows only one owner until the lock is released", () => {
246
+ const owner = acquireOAuthRefreshLock("lock-test", 60_000);
247
+ expect(typeof owner).toBe("string");
248
+
249
+ expect(acquireOAuthRefreshLock("lock-test", 60_000)).toBeNull();
250
+
251
+ releaseOAuthRefreshLock("lock-test", owner!);
252
+ const nextOwner = acquireOAuthRefreshLock("lock-test", 60_000);
253
+ expect(typeof nextOwner).toBe("string");
254
+ releaseOAuthRefreshLock("lock-test", nextOwner!);
255
+ });
256
+
257
+ test("allows a new owner after the lock expires", () => {
258
+ const expiredOwner = acquireOAuthRefreshLock("expired-lock-test", -1_000);
259
+ expect(typeof expiredOwner).toBe("string");
260
+
261
+ const nextOwner = acquireOAuthRefreshLock("expired-lock-test", 60_000);
262
+ expect(typeof nextOwner).toBe("string");
263
+ expect(nextOwner).not.toBe(expiredOwner);
264
+
265
+ releaseOAuthRefreshLock("expired-lock-test", nextOwner!);
266
+ });
267
+ });
@@ -300,6 +300,77 @@ describe("ensureTokenOrThrow", () => {
300
300
  expect(tokens?.refreshToken).toBe("new-jira-refresh");
301
301
  });
302
302
 
303
+ test("serializes concurrent Jira refresh callers before the token endpoint", async () => {
304
+ storeOAuthTokens("jira", {
305
+ accessToken: "old-jira-access",
306
+ refreshToken: "old-jira-refresh",
307
+ expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
308
+ });
309
+
310
+ const fetchSpy = mock(() =>
311
+ Promise.resolve(
312
+ new Response(
313
+ JSON.stringify({
314
+ access_token: "new-jira-access",
315
+ token_type: "Bearer",
316
+ expires_in: 3600,
317
+ refresh_token: "new-jira-refresh",
318
+ }),
319
+ { status: 200, headers: { "Content-Type": "application/json" } },
320
+ ),
321
+ ),
322
+ );
323
+ globalThis.fetch = fetchSpy;
324
+
325
+ await Promise.all([
326
+ ensureTokenOrThrow("jira"),
327
+ ensureTokenOrThrow("jira"),
328
+ ensureTokenOrThrow("jira"),
329
+ ]);
330
+
331
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
332
+ const [_url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
333
+ expect(init.body).toContain("refresh_token=old-jira-refresh");
334
+
335
+ const tokens = getOAuthTokens("jira");
336
+ expect(tokens?.accessToken).toBe("new-jira-access");
337
+ expect(tokens?.refreshToken).toBe("new-jira-refresh");
338
+ });
339
+
340
+ test("does not rotate again when a concurrent caller already changed the token row", async () => {
341
+ storeOAuthTokens("jira", {
342
+ accessToken: "old-jira-access",
343
+ refreshToken: "old-jira-refresh",
344
+ expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
345
+ });
346
+
347
+ const fetchSpy = mock(() =>
348
+ Promise.resolve(
349
+ new Response(
350
+ JSON.stringify({
351
+ access_token: "new-jira-access",
352
+ token_type: "Bearer",
353
+ expires_in: 3600,
354
+ refresh_token: "new-jira-refresh",
355
+ }),
356
+ { status: 200, headers: { "Content-Type": "application/json" } },
357
+ ),
358
+ ),
359
+ );
360
+ globalThis.fetch = fetchSpy;
361
+
362
+ await Promise.all([
363
+ ensureTokenOrThrow("jira", 65 * 60 * 1000),
364
+ ensureTokenOrThrow("jira", 65 * 60 * 1000),
365
+ ensureTokenOrThrow("jira", 65 * 60 * 1000),
366
+ ]);
367
+
368
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
369
+ const tokens = getOAuthTokens("jira");
370
+ expect(tokens?.accessToken).toBe("new-jira-access");
371
+ expect(tokens?.refreshToken).toBe("new-jira-refresh");
372
+ });
373
+
303
374
  test("rejects a Jira refresh response that omits the rotated refresh token", async () => {
304
375
  storeOAuthTokens("jira", {
305
376
  accessToken: "old-jira-access",
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { safeRequestUrlForLog } from "../http/utils";
3
+
4
+ describe("safeRequestUrlForLog", () => {
5
+ test("redacts OAuth callback query values", () => {
6
+ expect(
7
+ safeRequestUrlForLog(
8
+ "/api/trackers/jira/callback?state=opaque-state-value&code=oauth-code-value",
9
+ ),
10
+ ).toBe("/api/trackers/jira/callback?state=[REDACTED]&code=[REDACTED]");
11
+ });
12
+
13
+ test("preserves paths without query strings", () => {
14
+ expect(safeRequestUrlForLog("/api/trackers/jira/authorize")).toBe(
15
+ "/api/trackers/jira/authorize",
16
+ );
17
+ });
18
+
19
+ test("redacts every query parameter value in order", () => {
20
+ expect(safeRequestUrlForLog("/mcp?session=abc&session=def&token=secret")).toBe(
21
+ "/mcp?session=[REDACTED]&session=[REDACTED]&token=[REDACTED]",
22
+ );
23
+ });
24
+ });
@@ -5,6 +5,7 @@ import {
5
5
  createAgent,
6
6
  createPage,
7
7
  createScheduledTask,
8
+ createSessionCost,
8
9
  createTaskExtended,
9
10
  createWorkflow,
10
11
  getAllAgents,
@@ -145,7 +146,25 @@ describe("list-endpoint slimming", () => {
145
146
 
146
147
  test("getAllTasks — slim truncates task text and drops heavy blobs", () => {
147
148
  const longText = "Z".repeat(2000);
148
- createTaskExtended(longText, { agentId: "slim-agent-1" });
149
+ const task = createTaskExtended(longText, { agentId: "slim-agent-1" });
150
+ createSessionCost({
151
+ sessionId: "slim-cost-session-1",
152
+ taskId: task.id,
153
+ agentId: "slim-agent-1",
154
+ totalCostUsd: 0.0123,
155
+ durationMs: 1000,
156
+ numTurns: 1,
157
+ model: "test-model",
158
+ });
159
+ createSessionCost({
160
+ sessionId: "slim-cost-session-2",
161
+ taskId: task.id,
162
+ agentId: "slim-agent-1",
163
+ totalCostUsd: 0.0045,
164
+ durationMs: 1000,
165
+ numTurns: 1,
166
+ model: "test-model",
167
+ });
149
168
 
150
169
  const slim = getAllTasks({}, { slim: true });
151
170
  const slimTask = slim.find((t) => t.task.startsWith("Z"));
@@ -155,10 +174,12 @@ describe("list-endpoint slimming", () => {
155
174
  expect("output" in slimTask!).toBe(false);
156
175
  expect("failureReason" in slimTask!).toBe(false);
157
176
  expect("providerMeta" in slimTask!).toBe(false);
177
+ expect(slimTask?.totalCostUsd).toBeCloseTo(0.0168, 6);
158
178
 
159
179
  const full = getAllTasks({}).find((t) => t.task === longText);
160
180
  expect(full).toBeDefined();
161
181
  expect(full?.task).toBe(longText);
182
+ expect(full?.totalCostUsd).toBeCloseTo(0.0168, 6);
162
183
  });
163
184
 
164
185
  test("listRecentSessions — slim root is a truncated task summary", () => {
@@ -0,0 +1,138 @@
1
+ import { afterAll, afterEach, 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 { resolveOAuthAccessToken } from "../tools/oauth-access-token";
11
+ import {
12
+ clearVolatileSecretsForTesting,
13
+ refreshSecretScrubberCache,
14
+ scrubSecrets,
15
+ } from "../utils/secret-scrubber";
16
+
17
+ const TEST_DB_PATH = "./test-oauth-access-token-tool.sqlite";
18
+ const originalFetch = globalThis.fetch;
19
+
20
+ const testApp = {
21
+ clientId: "client-id",
22
+ clientSecret: "client-secret",
23
+ authorizeUrl: "https://example.com/oauth/authorize",
24
+ tokenUrl: "https://example.com/oauth/token",
25
+ redirectUri: "http://localhost:3013/callback",
26
+ scopes: "read,write",
27
+ };
28
+
29
+ beforeAll(() => {
30
+ initDb(TEST_DB_PATH);
31
+ upsertOAuthApp("linear", testApp);
32
+ upsertOAuthApp("jira", {
33
+ ...testApp,
34
+ tokenUrl: "https://example.com/jira/oauth/token",
35
+ });
36
+ upsertOAuthApp("custom-provider", {
37
+ ...testApp,
38
+ tokenUrl: "https://example.com/custom/oauth/token",
39
+ });
40
+ });
41
+
42
+ beforeEach(() => {
43
+ deleteOAuthTokens("linear");
44
+ deleteOAuthTokens("jira");
45
+ deleteOAuthTokens("custom-provider");
46
+ globalThis.fetch = originalFetch;
47
+ clearVolatileSecretsForTesting();
48
+ refreshSecretScrubberCache();
49
+ });
50
+
51
+ afterEach(() => {
52
+ globalThis.fetch = originalFetch;
53
+ clearVolatileSecretsForTesting();
54
+ refreshSecretScrubberCache();
55
+ });
56
+
57
+ afterAll(async () => {
58
+ globalThis.fetch = originalFetch;
59
+ closeDb();
60
+ await unlink(TEST_DB_PATH).catch(() => {});
61
+ await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
62
+ await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
63
+ });
64
+
65
+ describe("resolveOAuthAccessToken", () => {
66
+ test("returns a fresh access token and registers it for scrubber redaction", async () => {
67
+ const accessToken = "linear-access-token-plain-value-1234567890";
68
+ storeOAuthTokens("linear", {
69
+ accessToken,
70
+ refreshToken: "linear-refresh-token",
71
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
72
+ });
73
+
74
+ const result = await resolveOAuthAccessToken("linear");
75
+
76
+ expect(result).toEqual({
77
+ provider: "linear",
78
+ accessToken,
79
+ expiresAt: result.expiresAt,
80
+ tokenType: "Bearer",
81
+ });
82
+ expect(scrubSecrets(`Authorization: Bearer ${accessToken}`)).toBe(
83
+ "Authorization: Bearer [REDACTED:LINEAR_OAUTH_ACCESS_TOKEN]",
84
+ );
85
+ });
86
+
87
+ test("supports any configured OAuth provider slug", async () => {
88
+ storeOAuthTokens("custom-provider", {
89
+ accessToken: "custom-provider-access-token-plain-value",
90
+ refreshToken: "custom-provider-refresh-token",
91
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
92
+ });
93
+
94
+ const result = await resolveOAuthAccessToken("custom-provider");
95
+
96
+ expect(result.provider).toBe("custom-provider");
97
+ expect(result.accessToken).toBe("custom-provider-access-token-plain-value");
98
+ });
99
+
100
+ test("refreshes Jira before returning a near-expiry token", async () => {
101
+ storeOAuthTokens("jira", {
102
+ accessToken: "old-jira-access-token",
103
+ refreshToken: "old-jira-refresh-token",
104
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
105
+ });
106
+
107
+ const fetchSpy = mock(() =>
108
+ Promise.resolve(
109
+ new Response(
110
+ JSON.stringify({
111
+ access_token: "new-jira-access-token-plain-value",
112
+ token_type: "Bearer",
113
+ expires_in: 3600,
114
+ refresh_token: "new-jira-refresh-token",
115
+ }),
116
+ { status: 200, headers: { "Content-Type": "application/json" } },
117
+ ),
118
+ ),
119
+ );
120
+ globalThis.fetch = fetchSpy;
121
+
122
+ const result = await resolveOAuthAccessToken("jira");
123
+
124
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
125
+ expect(result.accessToken).toBe("new-jira-access-token-plain-value");
126
+ expect(getOAuthTokens("jira")?.refreshToken).toBe("new-jira-refresh-token");
127
+ });
128
+
129
+ test("rejects a near-expiry token when no refresh token is available", async () => {
130
+ storeOAuthTokens("jira", {
131
+ accessToken: "stale-jira-access-token",
132
+ refreshToken: null,
133
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
134
+ });
135
+
136
+ await expect(resolveOAuthAccessToken("jira")).rejects.toThrow(/could not be refreshed/);
137
+ });
138
+ });
@@ -1,7 +1,12 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { createPiRuntimeAuth, PiMonoAdapter, resolveModel } from "../providers/pi-mono-adapter";
4
+ import {
5
+ createPiRuntimeAuth,
6
+ extractPiAssistantText,
7
+ PiMonoAdapter,
8
+ resolveModel,
9
+ } from "../providers/pi-mono-adapter";
5
10
 
6
11
  describe("PiMonoAdapter", () => {
7
12
  test("name is 'pi'", () => {
@@ -198,6 +203,37 @@ describe("createPiRuntimeAuth", () => {
198
203
  });
199
204
 
200
205
  describe("Pi-mono event normalization", () => {
206
+ test("extractPiAssistantText ignores user messages", () => {
207
+ const text = extractPiAssistantText({
208
+ role: "user",
209
+ content: "/skill:work-on-task task-123\n\nTask: hello",
210
+ });
211
+
212
+ expect(text).toBe("");
213
+ });
214
+
215
+ test("extractPiAssistantText extracts assistant text blocks", () => {
216
+ const text = extractPiAssistantText({
217
+ role: "assistant",
218
+ content: [
219
+ { type: "text", text: "Hello, " },
220
+ { type: "thinking", thinking: "hidden" },
221
+ { type: "text", text: "world!" },
222
+ ],
223
+ });
224
+
225
+ expect(text).toBe("Hello, world!");
226
+ });
227
+
228
+ test("extractPiAssistantText supports string assistant content", () => {
229
+ const text = extractPiAssistantText({
230
+ role: "assistant",
231
+ content: "Plain assistant output",
232
+ });
233
+
234
+ expect(text).toBe("Plain assistant output");
235
+ });
236
+
201
237
  test("message_update with text content produces raw_log-style data", () => {
202
238
  // Simulates what PiMonoSession.handleAgentEvent does
203
239
  const event = {
@@ -1,76 +1,70 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { createServer as createHttpServer, type Server } from "node:http";
3
2
  import {
4
3
  type ApiConfig,
5
4
  ensureTaskFinished,
6
5
  handleStructuredOutputFallback,
7
6
  } from "../commands/runner";
8
7
 
9
- const TEST_PORT = 13099;
10
-
11
8
  // Configurable mock responses per test
12
9
  let mockGetTask: Record<string, unknown> | null = null;
13
10
  let mockGetTaskStatus = 200;
14
11
  let lastFinishBody: Record<string, unknown> | null = null;
15
12
  let mockFinishResponse: Record<string, unknown> = { success: true };
13
+ let mockFetchError: Error | null = null;
14
+ let originalFetch: typeof fetch;
16
15
 
17
16
  function resetMocks() {
18
17
  mockGetTask = null;
19
18
  mockGetTaskStatus = 200;
20
19
  lastFinishBody = null;
21
20
  mockFinishResponse = { success: true };
21
+ mockFetchError = null;
22
22
  }
23
23
 
24
- let server: Server;
25
-
26
- function makeConfig(port = TEST_PORT): ApiConfig {
24
+ function makeConfig(): ApiConfig {
27
25
  return {
28
- apiUrl: `http://localhost:${port}`,
26
+ apiUrl: "http://runner-fallback.test",
29
27
  apiKey: "test-key",
30
28
  agentId: "test-agent-id",
31
29
  };
32
30
  }
33
31
 
34
- beforeAll(async () => {
35
- server = createHttpServer(async (req, res) => {
36
- const chunks: Buffer[] = [];
37
- for await (const chunk of req) {
38
- chunks.push(chunk);
39
- }
40
- const body = Buffer.concat(chunks).toString();
41
- const url = req.url || "";
32
+ beforeAll(() => {
33
+ originalFetch = globalThis.fetch;
34
+ globalThis.fetch = (async (input, init) => {
35
+ if (mockFetchError) throw mockFetchError;
42
36
 
43
- // GET /api/tasks/:id
44
- if (req.method === "GET" && /^\/api\/tasks\/[^/]+$/.test(url)) {
37
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
38
+ const parsedUrl = new URL(url);
39
+ const method = init?.method ?? "GET";
40
+
41
+ if (method === "GET" && /^\/api\/tasks\/[^/]+$/.test(parsedUrl.pathname)) {
45
42
  if (!mockGetTask) {
46
- res.writeHead(mockGetTaskStatus);
47
- res.end(JSON.stringify({ error: "Not found" }));
48
- return;
43
+ return new Response(JSON.stringify({ error: "Not found" }), {
44
+ status: mockGetTaskStatus,
45
+ });
49
46
  }
50
- res.writeHead(mockGetTaskStatus, { "Content-Type": "application/json" });
51
- res.end(JSON.stringify(mockGetTask));
52
- return;
47
+ return new Response(JSON.stringify(mockGetTask), {
48
+ status: mockGetTaskStatus,
49
+ headers: { "Content-Type": "application/json" },
50
+ });
53
51
  }
54
52
 
55
- // POST /api/tasks/:id/finish
56
- if (req.method === "POST" && /^\/api\/tasks\/[^/]+\/finish$/.test(url)) {
53
+ if (method === "POST" && /^\/api\/tasks\/[^/]+\/finish$/.test(parsedUrl.pathname)) {
54
+ const body = typeof init?.body === "string" ? init.body : "";
57
55
  lastFinishBody = body ? JSON.parse(body) : null;
58
- res.writeHead(200, { "Content-Type": "application/json" });
59
- res.end(JSON.stringify(mockFinishResponse));
60
- return;
56
+ return new Response(JSON.stringify(mockFinishResponse), {
57
+ status: 200,
58
+ headers: { "Content-Type": "application/json" },
59
+ });
61
60
  }
62
61
 
63
- res.writeHead(404);
64
- res.end("Not found");
65
- });
66
-
67
- await new Promise<void>((resolve) => {
68
- server.listen(TEST_PORT, () => resolve());
69
- });
62
+ return new Response("Not found", { status: 404 });
63
+ }) as typeof fetch;
70
64
  });
71
65
 
72
66
  afterAll(() => {
73
- server.close();
67
+ globalThis.fetch = originalFetch;
74
68
  });
75
69
 
76
70
  describe("handleStructuredOutputFallback", () => {
@@ -175,10 +169,9 @@ describe("handleStructuredOutputFallback", () => {
175
169
 
176
170
  test("returns fetch-error on network error", async () => {
177
171
  resetMocks();
178
- // Use a port that nothing listens on
179
- const badConfig = makeConfig(19999);
172
+ mockFetchError = new Error("network down");
180
173
 
181
- const result = await handleStructuredOutputFallback(badConfig, "task-7", "claude");
174
+ const result = await handleStructuredOutputFallback(makeConfig(), "task-7", "claude");
182
175
  expect(result.kind).toBe("fetch-error");
183
176
  expect((result as { kind: "fetch-error"; error: string }).error).toBeTruthy();
184
177
  });
@@ -227,6 +220,92 @@ describe("ensureTaskFinished", () => {
227
220
  expect(lastFinishBody!.output).toBe("Process completed successfully (no output captured)");
228
221
  });
229
222
 
223
+ test("uses provider output when no outputSchema exists", async () => {
224
+ resetMocks();
225
+ mockGetTask = {
226
+ id: "task-provider-output",
227
+ task: "Do work",
228
+ status: "in_progress",
229
+ output: null,
230
+ progress: null,
231
+ logs: [],
232
+ };
233
+
234
+ await ensureTaskFinished(
235
+ makeConfig(),
236
+ "worker",
237
+ "task-provider-output",
238
+ 0,
239
+ undefined,
240
+ "Provider final answer",
241
+ "pi",
242
+ );
243
+
244
+ expect(lastFinishBody).toBeTruthy();
245
+ expect(lastFinishBody!.status).toBe("completed");
246
+ expect(lastFinishBody!.output).toBe("Provider final answer");
247
+ });
248
+
249
+ test("accepts provider output that satisfies outputSchema", async () => {
250
+ resetMocks();
251
+ mockGetTask = {
252
+ id: "task-provider-schema-valid",
253
+ task: "Do work",
254
+ status: "in_progress",
255
+ output: null,
256
+ outputSchema: {
257
+ type: "object",
258
+ required: ["result"],
259
+ properties: { result: { type: "string" } },
260
+ },
261
+ logs: [],
262
+ };
263
+
264
+ await ensureTaskFinished(
265
+ makeConfig(),
266
+ "worker",
267
+ "task-provider-schema-valid",
268
+ 0,
269
+ undefined,
270
+ '{"result":"ok"}',
271
+ "pi",
272
+ );
273
+
274
+ expect(lastFinishBody).toBeTruthy();
275
+ expect(lastFinishBody!.status).toBe("completed");
276
+ expect(lastFinishBody!.output).toBe('{"result":"ok"}');
277
+ });
278
+
279
+ test("fails provider output that violates outputSchema", async () => {
280
+ resetMocks();
281
+ mockGetTask = {
282
+ id: "task-provider-schema-invalid",
283
+ task: "Do work",
284
+ status: "in_progress",
285
+ output: null,
286
+ outputSchema: {
287
+ type: "object",
288
+ required: ["result"],
289
+ properties: { result: { type: "string" } },
290
+ },
291
+ logs: [],
292
+ };
293
+
294
+ await ensureTaskFinished(
295
+ makeConfig(),
296
+ "worker",
297
+ "task-provider-schema-invalid",
298
+ 0,
299
+ undefined,
300
+ "plain text",
301
+ "pi",
302
+ );
303
+
304
+ expect(lastFinishBody).toBeTruthy();
305
+ expect(lastFinishBody!.status).toBe("failed");
306
+ expect(lastFinishBody!.failureReason).toContain("outputSchema");
307
+ });
308
+
230
309
  test("sets failed status for schema-fail fallback", async () => {
231
310
  resetMocks();
232
311
  mockGetTask = {