@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.
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/be/db-queries/oauth.ts +33 -0
- package/src/be/db.ts +7 -1
- package/src/be/migrations/077_oauth_refresh_locks.sql +8 -0
- package/src/commands/runner.ts +59 -6
- package/src/http/index.ts +11 -3
- package/src/http/tasks.ts +17 -0
- package/src/http/utils.ts +17 -0
- package/src/oauth/ensure-token.ts +97 -11
- package/src/providers/pi-mono-adapter.ts +44 -25
- package/src/server.ts +2 -0
- package/src/tasks/worker-follow-up.ts +82 -0
- package/src/tests/agents-list-model-display.test.ts +13 -1
- package/src/tests/db-queries-oauth.test.ts +27 -0
- package/src/tests/ensure-token.test.ts +71 -0
- package/src/tests/http-log-scrubbing.test.ts +24 -0
- package/src/tests/list-endpoint-slimming.test.ts +22 -1
- package/src/tests/oauth-access-token-tool.test.ts +138 -0
- package/src/tests/pi-mono-adapter.test.ts +37 -1
- package/src/tests/runner-fallback-output.test.ts +118 -39
- package/src/tests/task-completion-idempotency.test.ts +89 -0
- package/src/tools/oauth-access-token.ts +118 -0
- package/src/tools/store-progress.ts +12 -77
- package/src/tools/tool-config.ts +2 -1
- package/src/types.ts +5 -0
- package/src/utils/secret-scrubber.ts +23 -0
|
@@ -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 {
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
function makeConfig(port = TEST_PORT): ApiConfig {
|
|
24
|
+
function makeConfig(): ApiConfig {
|
|
27
25
|
return {
|
|
28
|
-
apiUrl:
|
|
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(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
return new Response(JSON.stringify({ error: "Not found" }), {
|
|
44
|
+
status: mockGetTaskStatus,
|
|
45
|
+
});
|
|
49
46
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
return new Response(JSON.stringify(mockGetTask), {
|
|
48
|
+
status: mockGetTaskStatus,
|
|
49
|
+
headers: { "Content-Type": "application/json" },
|
|
50
|
+
});
|
|
53
51
|
}
|
|
54
52
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
return new Response(JSON.stringify(mockFinishResponse), {
|
|
57
|
+
status: 200,
|
|
58
|
+
headers: { "Content-Type": "application/json" },
|
|
59
|
+
});
|
|
61
60
|
}
|
|
62
61
|
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
const badConfig = makeConfig(19999);
|
|
172
|
+
mockFetchError = new Error("network down");
|
|
180
173
|
|
|
181
|
-
const result = await handleStructuredOutputFallback(
|
|
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 = {
|