@desplega.ai/agent-swarm 1.75.0 → 1.76.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 (48) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +973 -36
  3. package/package.json +2 -2
  4. package/src/be/db.ts +527 -9
  5. package/src/be/memory/raters/llm-summarizer.ts +218 -0
  6. package/src/be/memory/raters/llm.ts +56 -75
  7. package/src/be/memory/retrieval-store.ts +21 -0
  8. package/src/be/migrations/054_agent_harness_provider.sql +21 -0
  9. package/src/be/migrations/055_agent_cred_status.sql +15 -0
  10. package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
  11. package/src/be/migrations/057_inbox_item_state.sql +27 -0
  12. package/src/be/migrations/058_task_templates.sql +31 -0
  13. package/src/be/swarm-config-guard.ts +24 -0
  14. package/src/commands/credential-wait.ts +1 -1
  15. package/src/commands/provider-credentials.ts +434 -0
  16. package/src/commands/runner.ts +229 -42
  17. package/src/hooks/hook.ts +115 -95
  18. package/src/http/agents.ts +82 -2
  19. package/src/http/config.ts +11 -1
  20. package/src/http/inbox-state.ts +89 -0
  21. package/src/http/index.ts +10 -0
  22. package/src/http/sessions.ts +86 -0
  23. package/src/http/status.ts +665 -0
  24. package/src/http/task-templates.ts +51 -0
  25. package/src/http/tasks.ts +85 -5
  26. package/src/http/users.ts +134 -0
  27. package/src/providers/claude-adapter.ts +5 -0
  28. package/src/providers/codex-adapter.ts +1 -1
  29. package/src/providers/index.ts +1 -1
  30. package/src/slack/handlers.ts +0 -1
  31. package/src/tests/agents-harness-provider.test.ts +333 -0
  32. package/src/tests/credential-check.test.ts +32 -1
  33. package/src/tests/credential-status-api.test.ts +42 -0
  34. package/src/tests/harness-provider-resolution.test.ts +242 -0
  35. package/src/tests/jira-sync.test.ts +1 -1
  36. package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
  37. package/src/tests/memory-rater-llm.test.ts +265 -107
  38. package/src/tests/migration-runner-regressions.test.ts +17 -2
  39. package/src/tests/sessions.test.ts +141 -0
  40. package/src/tests/status.test.ts +843 -0
  41. package/src/tests/stop-hook-task-resolution.test.ts +98 -0
  42. package/src/tests/template-recommendations.test.ts +148 -0
  43. package/src/tests/use-dismissible-card.test.ts +140 -0
  44. package/src/tools/swarm-config/set-config.ts +17 -1
  45. package/src/types.ts +117 -0
  46. package/src/utils/harness-provider.ts +32 -0
  47. package/tsconfig.json +0 -2
  48. package/src/providers/credentials.ts +0 -74
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Phase 1.5 (cloud-personalization): per-agent harness_provider column +
3
+ * worker registration push + PATCH /api/agents/:id/harness-provider.
4
+ *
5
+ * Coverage:
6
+ * - Migration applies cleanly (the test bootstrap runs `initDb` which
7
+ * applies all migrations forward-only; existence of the column is
8
+ * verified via PRAGMA below).
9
+ * - Worker registration with `harness_provider` writes the column.
10
+ * - Re-registration updates the column when the value changes.
11
+ * - `PATCH /api/agents/:id/harness-provider` updates the column.
12
+ * - Invalid provider names rejected with 400.
13
+ */
14
+
15
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
16
+ import { unlink } from "node:fs/promises";
17
+ import { createServer as createHttpServer, type Server } from "node:http";
18
+ import {
19
+ closeDb,
20
+ createAgent,
21
+ getAgentById,
22
+ getAgentHarnessProviders,
23
+ getDb,
24
+ getSwarmConfigs,
25
+ initDb,
26
+ setAgentHarnessProvider,
27
+ } from "../be/db";
28
+ import { handleAgentRegister, handleAgentsRest } from "../http/agents";
29
+
30
+ const TEST_DB_PATH = "./test-agents-harness-provider.sqlite";
31
+ const TEST_PORT = 13059;
32
+
33
+ async function removeDbFiles(path: string): Promise<void> {
34
+ for (const suffix of ["", "-wal", "-shm"]) {
35
+ try {
36
+ await unlink(path + suffix);
37
+ } catch (error) {
38
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
39
+ }
40
+ }
41
+ }
42
+
43
+ function makeTestServer(): Server {
44
+ return createHttpServer(async (req, res) => {
45
+ const url = new URL(req.url ?? "/", `http://localhost:${TEST_PORT}`);
46
+ const pathSegments = url.pathname.split("/").filter(Boolean);
47
+ const queryParams = url.searchParams;
48
+ const myAgentId = (req.headers["x-agent-id"] as string | undefined) ?? undefined;
49
+
50
+ try {
51
+ if (await handleAgentRegister(req, res, pathSegments, myAgentId)) return;
52
+ if (await handleAgentsRest(req, res, pathSegments, queryParams, myAgentId)) return;
53
+ } catch (err) {
54
+ res.writeHead(500, { "Content-Type": "application/json" });
55
+ res.end(JSON.stringify({ error: (err as Error).message }));
56
+ return;
57
+ }
58
+ res.writeHead(404, { "Content-Type": "application/json" });
59
+ res.end(JSON.stringify({ error: "Not found" }));
60
+ });
61
+ }
62
+
63
+ let server: Server;
64
+ const baseUrl = `http://localhost:${TEST_PORT}`;
65
+
66
+ beforeAll(async () => {
67
+ await removeDbFiles(TEST_DB_PATH);
68
+ initDb(TEST_DB_PATH);
69
+ server = makeTestServer();
70
+ await new Promise<void>((resolve) => {
71
+ server.listen(TEST_PORT, () => resolve());
72
+ });
73
+ });
74
+
75
+ afterAll(async () => {
76
+ await new Promise<void>((resolve) => {
77
+ server.close(() => resolve());
78
+ });
79
+ closeDb();
80
+ await removeDbFiles(TEST_DB_PATH);
81
+ });
82
+
83
+ beforeEach(() => {
84
+ // Each test starts on an empty agents table.
85
+ getDb().prepare("DELETE FROM agents").run();
86
+ getDb().prepare("DELETE FROM swarm_config").run();
87
+ });
88
+
89
+ // ─── Migration: column exists ────────────────────────────────────────────────
90
+
91
+ describe("migration 054_agent_harness_provider", () => {
92
+ test("`harness_provider` column exists on the `agents` table", () => {
93
+ const cols = getDb()
94
+ .prepare<{ name: string }, []>(`PRAGMA table_info(agents)`)
95
+ .all()
96
+ .map((r) => r.name);
97
+ expect(cols).toContain("harness_provider");
98
+ });
99
+
100
+ test("existing agent rows default to NULL `harness_provider`", () => {
101
+ const a = createAgent({
102
+ name: "legacy-agent",
103
+ isLead: false,
104
+ status: "idle",
105
+ capabilities: [],
106
+ });
107
+ expect(a.harnessProvider).toBeNull();
108
+ });
109
+ });
110
+
111
+ // ─── DB helpers ──────────────────────────────────────────────────────────────
112
+
113
+ describe("DB helpers", () => {
114
+ test("setAgentHarnessProvider writes and returns the updated row", () => {
115
+ const a = createAgent({ name: "a1", isLead: false, status: "idle", capabilities: [] });
116
+ expect(a.harnessProvider).toBeNull();
117
+
118
+ const updated = setAgentHarnessProvider(a.id, "codex");
119
+ expect(updated?.harnessProvider).toBe("codex");
120
+
121
+ const fetched = getAgentById(a.id);
122
+ expect(fetched?.harnessProvider).toBe("codex");
123
+ });
124
+
125
+ test("setAgentHarnessProvider can clear the column with null", () => {
126
+ const a = createAgent({
127
+ name: "a-clear",
128
+ isLead: false,
129
+ status: "idle",
130
+ capabilities: [],
131
+ harnessProvider: "claude",
132
+ });
133
+ expect(a.harnessProvider).toBe("claude");
134
+
135
+ const updated = setAgentHarnessProvider(a.id, null);
136
+ expect(updated?.harnessProvider).toBeNull();
137
+ });
138
+
139
+ test("setAgentHarnessProvider returns null when agent not found", () => {
140
+ const result = setAgentHarnessProvider("nonexistent-id", "claude");
141
+ expect(result).toBeNull();
142
+ });
143
+
144
+ test("getAgentHarnessProviders aggregates by provider, excluding NULL", () => {
145
+ createAgent({
146
+ name: "x1",
147
+ isLead: false,
148
+ status: "idle",
149
+ capabilities: [],
150
+ harnessProvider: "claude",
151
+ });
152
+ createAgent({
153
+ name: "x2",
154
+ isLead: false,
155
+ status: "idle",
156
+ capabilities: [],
157
+ harnessProvider: "claude",
158
+ });
159
+ createAgent({
160
+ name: "x3",
161
+ isLead: false,
162
+ status: "idle",
163
+ capabilities: [],
164
+ harnessProvider: "codex",
165
+ });
166
+ createAgent({ name: "x4", isLead: false, status: "idle", capabilities: [] }); // NULL — excluded
167
+
168
+ const counts = getAgentHarnessProviders();
169
+ expect(counts).toEqual([
170
+ { provider: "claude", count: 2 },
171
+ { provider: "codex", count: 1 },
172
+ ]);
173
+ });
174
+ });
175
+
176
+ // ─── Worker registration: HTTP path ──────────────────────────────────────────
177
+
178
+ describe("POST /api/agents — worker registration pushes harness_provider", () => {
179
+ test("first-time register persists harness_provider", async () => {
180
+ const agentId = "agent-register-1";
181
+ const res = await fetch(`${baseUrl}/api/agents`, {
182
+ method: "POST",
183
+ headers: { "Content-Type": "application/json", "X-Agent-ID": agentId },
184
+ body: JSON.stringify({
185
+ name: "worker-fresh",
186
+ isLead: false,
187
+ harness_provider: "claude",
188
+ }),
189
+ });
190
+ expect(res.status).toBe(201);
191
+
192
+ const row = getAgentById(agentId);
193
+ expect(row?.harnessProvider).toBe("claude");
194
+ });
195
+
196
+ test("re-register with a different harness_provider updates the column", async () => {
197
+ const agentId = "agent-register-2";
198
+ // First register with claude.
199
+ await fetch(`${baseUrl}/api/agents`, {
200
+ method: "POST",
201
+ headers: { "Content-Type": "application/json", "X-Agent-ID": agentId },
202
+ body: JSON.stringify({ name: "worker-rotating", isLead: false, harness_provider: "claude" }),
203
+ });
204
+
205
+ // Re-register with codex.
206
+ const res = await fetch(`${baseUrl}/api/agents`, {
207
+ method: "POST",
208
+ headers: { "Content-Type": "application/json", "X-Agent-ID": agentId },
209
+ body: JSON.stringify({ name: "worker-rotating", isLead: false, harness_provider: "codex" }),
210
+ });
211
+ expect(res.status).toBe(200);
212
+
213
+ const row = getAgentById(agentId);
214
+ expect(row?.harnessProvider).toBe("codex");
215
+ });
216
+
217
+ test("registration WITHOUT harness_provider leaves an existing column value untouched", async () => {
218
+ const agentId = "agent-register-3";
219
+ // First register with claude.
220
+ await fetch(`${baseUrl}/api/agents`, {
221
+ method: "POST",
222
+ headers: { "Content-Type": "application/json", "X-Agent-ID": agentId },
223
+ body: JSON.stringify({ name: "worker-quiet", isLead: false, harness_provider: "claude" }),
224
+ });
225
+
226
+ // Re-register without harness_provider (older worker).
227
+ const res = await fetch(`${baseUrl}/api/agents`, {
228
+ method: "POST",
229
+ headers: { "Content-Type": "application/json", "X-Agent-ID": agentId },
230
+ body: JSON.stringify({ name: "worker-quiet", isLead: false }),
231
+ });
232
+ expect(res.status).toBe(200);
233
+
234
+ // Existing value preserved (so PATCH overrides aren't clobbered by
235
+ // older workers re-registering without the field).
236
+ const row = getAgentById(agentId);
237
+ expect(row?.harnessProvider).toBe("claude");
238
+ });
239
+
240
+ test("rejects an unknown provider name with 400", async () => {
241
+ const res = await fetch(`${baseUrl}/api/agents`, {
242
+ method: "POST",
243
+ headers: { "Content-Type": "application/json", "X-Agent-ID": "agent-bad" },
244
+ body: JSON.stringify({
245
+ name: "worker-bad-provider",
246
+ isLead: false,
247
+ harness_provider: "rogue-llm",
248
+ }),
249
+ });
250
+ expect(res.status).toBe(400);
251
+ });
252
+ });
253
+
254
+ // ─── PATCH /api/agents/:id/harness-provider ─────────────────────────────────
255
+
256
+ describe("PATCH /api/agents/:id/harness-provider", () => {
257
+ test("updates the column on a known agent", async () => {
258
+ const a = createAgent({
259
+ name: "patch-target-1",
260
+ isLead: false,
261
+ status: "idle",
262
+ capabilities: [],
263
+ });
264
+
265
+ const res = await fetch(`${baseUrl}/api/agents/${a.id}/harness-provider`, {
266
+ method: "PATCH",
267
+ headers: { "Content-Type": "application/json" },
268
+ body: JSON.stringify({ harness_provider: "codex" }),
269
+ });
270
+ expect(res.status).toBe(200);
271
+
272
+ const row = getAgentById(a.id);
273
+ expect(row?.harnessProvider).toBe("codex");
274
+ });
275
+
276
+ test("rejects unknown provider names with 400", async () => {
277
+ const a = createAgent({
278
+ name: "patch-target-2",
279
+ isLead: false,
280
+ status: "idle",
281
+ capabilities: [],
282
+ });
283
+
284
+ const res = await fetch(`${baseUrl}/api/agents/${a.id}/harness-provider`, {
285
+ method: "PATCH",
286
+ headers: { "Content-Type": "application/json" },
287
+ body: JSON.stringify({ harness_provider: "rogue" }),
288
+ });
289
+ expect(res.status).toBe(400);
290
+ });
291
+
292
+ test("returns 404 when agent does not exist", async () => {
293
+ const res = await fetch(`${baseUrl}/api/agents/nonexistent-agent-id/harness-provider`, {
294
+ method: "PATCH",
295
+ headers: { "Content-Type": "application/json" },
296
+ body: JSON.stringify({ harness_provider: "claude" }),
297
+ });
298
+ expect(res.status).toBe(404);
299
+ });
300
+
301
+ test("PATCH also upserts swarm_config (scope=agent) so the worker reconciles", async () => {
302
+ const a = createAgent({
303
+ name: "patch-target-3",
304
+ isLead: false,
305
+ status: "idle",
306
+ capabilities: [],
307
+ });
308
+
309
+ const res = await fetch(`${baseUrl}/api/agents/${a.id}/harness-provider`, {
310
+ method: "PATCH",
311
+ headers: { "Content-Type": "application/json" },
312
+ body: JSON.stringify({ harness_provider: "codex" }),
313
+ });
314
+ expect(res.status).toBe(200);
315
+
316
+ const rows = getSwarmConfigs({ scope: "agent", scopeId: a.id });
317
+ const harnessRow = rows.find((r) => r.key === "HARNESS_PROVIDER");
318
+ expect(harnessRow?.value).toBe("codex");
319
+
320
+ // Subsequent PATCH (different value) updates the row in place.
321
+ const res2 = await fetch(`${baseUrl}/api/agents/${a.id}/harness-provider`, {
322
+ method: "PATCH",
323
+ headers: { "Content-Type": "application/json" },
324
+ body: JSON.stringify({ harness_provider: "claude" }),
325
+ });
326
+ expect(res2.status).toBe(200);
327
+
328
+ const rows2 = getSwarmConfigs({ scope: "agent", scopeId: a.id });
329
+ const harnessRow2 = rows2.find((r) => r.key === "HARNESS_PROVIDER");
330
+ expect(harnessRow2?.value).toBe("claude");
331
+ expect(rows2.filter((r) => r.key === "HARNESS_PROVIDER")).toHaveLength(1);
332
+ });
333
+ });
@@ -1,8 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildCredStatusReport,
4
+ checkProviderCredentials,
5
+ isCredCheckDisabled,
6
+ REQUIRED_CRED_VARS_BY_PROVIDER,
7
+ } from "../commands/provider-credentials";
2
8
  import { checkClaudeCredentials } from "../providers/claude-adapter";
3
9
  import { checkClaudeManagedCredentials } from "../providers/claude-managed-adapter";
4
10
  import { checkCodexCredentials } from "../providers/codex-adapter";
5
- import { checkProviderCredentials, REQUIRED_CRED_VARS_BY_PROVIDER } from "../providers/credentials";
6
11
  import { checkDevinCredentials } from "../providers/devin-adapter";
7
12
  import { checkOpencodeCredentials } from "../providers/opencode-adapter";
8
13
  import { checkPiMonoCredentials } from "../providers/pi-mono-adapter";
@@ -334,3 +339,29 @@ describe("REQUIRED_CRED_VARS_BY_PROVIDER", () => {
334
339
  }
335
340
  });
336
341
  });
342
+
343
+ // ─── Migration 055: report composition + opt-out ────────────────────────────
344
+
345
+ describe("isCredCheckDisabled", () => {
346
+ test("true only when CRED_CHECK_DISABLE === '1'", () => {
347
+ expect(isCredCheckDisabled({})).toBe(false);
348
+ expect(isCredCheckDisabled({ CRED_CHECK_DISABLE: "0" })).toBe(false);
349
+ expect(isCredCheckDisabled({ CRED_CHECK_DISABLE: "true" })).toBe(false);
350
+ expect(isCredCheckDisabled({ CRED_CHECK_DISABLE: "1" })).toBe(true);
351
+ });
352
+ });
353
+
354
+ describe("buildCredStatusReport", () => {
355
+ test("not ready → no live test, snapshot mirrors presence check", async () => {
356
+ const snap = await buildCredStatusReport("claude", {}, {}, "boot");
357
+ expect(snap.ready).toBe(false);
358
+ expect(snap.liveTest).toBeNull();
359
+ expect(snap.reportKind).toBe("boot");
360
+ expect(snap.missing.length).toBeGreaterThan(0);
361
+ });
362
+
363
+ test("post_task kind is preserved on the snapshot", async () => {
364
+ const snap = await buildCredStatusReport("claude", {}, {}, "post_task");
365
+ expect(snap.reportKind).toBe("post_task");
366
+ });
367
+ });
@@ -178,4 +178,46 @@ describe("Phase 4 — credential-status HTTP endpoints", () => {
178
178
  });
179
179
  expect(resp.status).toBe(404);
180
180
  });
181
+
182
+ // Migration 055 — round-trip the new `cred_status` field.
183
+ test("PUT /credential-status round-trips a cred_status snapshot", async () => {
184
+ const snapshot = {
185
+ ready: true,
186
+ missing: [],
187
+ satisfiedBy: "env" as const,
188
+ hint: null,
189
+ liveTest: { ok: true, error: null, latency_ms: 91, testedAt: Date.now() },
190
+ reportedAt: Date.now(),
191
+ reportKind: "boot" as const,
192
+ };
193
+ const put = await fetch(`${baseUrl}/api/agents/${readyAgentId}/credential-status`, {
194
+ method: "PUT",
195
+ headers: { "Content-Type": "application/json" },
196
+ body: JSON.stringify({ ready: true, missing: [], cred_status: snapshot }),
197
+ });
198
+ expect(put.status).toBe(200);
199
+
200
+ const get = await fetch(`${baseUrl}/api/agents/${readyAgentId}/credential-status`);
201
+ const body = (await get.json()) as {
202
+ credStatus: typeof snapshot | null;
203
+ };
204
+ expect(body.credStatus).toMatchObject({
205
+ ready: true,
206
+ satisfiedBy: "env",
207
+ reportKind: "boot",
208
+ liveTest: { ok: true, latency_ms: 91 },
209
+ });
210
+ });
211
+
212
+ test("PUT rejects a malformed cred_status payload (Zod validation)", async () => {
213
+ const resp = await fetch(`${baseUrl}/api/agents/${readyAgentId}/credential-status`, {
214
+ method: "PUT",
215
+ headers: { "Content-Type": "application/json" },
216
+ body: JSON.stringify({
217
+ ready: true,
218
+ cred_status: { ready: "not-a-boolean", reportedAt: Date.now() }, // bad shape
219
+ }),
220
+ });
221
+ expect(resp.status).toBe(400);
222
+ });
181
223
  });
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Coverage for the swarm_config-overrides-HARNESS_PROVIDER work:
3
+ *
4
+ * - `resolveHarnessProvider` precedence (resolvedEnv > fallbackEnv > "claude")
5
+ * and invalid-value fallback.
6
+ * - `validateConfigValue` rejects unknown providers (used by HTTP +
7
+ * MCP write paths).
8
+ * - `getResolvedConfig` honours scope precedence (repo > agent > global)
9
+ * for HARNESS_PROVIDER, mirroring how MODEL_OVERRIDE already works.
10
+ * - End-to-end through `PUT /api/config`: a typo'd HARNESS_PROVIDER is
11
+ * rejected with 400 instead of being silently stored.
12
+ */
13
+
14
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
15
+ import { unlink } from "node:fs/promises";
16
+ import { createServer as createHttpServer, type Server } from "node:http";
17
+ import {
18
+ closeDb,
19
+ createAgent,
20
+ getDb,
21
+ getResolvedConfig,
22
+ initDb,
23
+ upsertSwarmConfig,
24
+ } from "../be/db";
25
+ import { validateConfigValue } from "../be/swarm-config-guard";
26
+ import { handleConfig } from "../http/config";
27
+ import { resolveHarnessProvider } from "../utils/harness-provider";
28
+
29
+ const TEST_DB_PATH = "./test-harness-provider-resolution.sqlite";
30
+ const TEST_PORT = 13061;
31
+
32
+ async function removeDbFiles(path: string): Promise<void> {
33
+ for (const suffix of ["", "-wal", "-shm"]) {
34
+ try {
35
+ await unlink(path + suffix);
36
+ } catch (error) {
37
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
38
+ }
39
+ }
40
+ }
41
+
42
+ function makeTestServer(): Server {
43
+ return createHttpServer(async (req, res) => {
44
+ const url = new URL(req.url ?? "/", `http://localhost:${TEST_PORT}`);
45
+ const pathSegments = url.pathname.split("/").filter(Boolean);
46
+ const queryParams = url.searchParams;
47
+ try {
48
+ if (await handleConfig(req, res, pathSegments, queryParams)) return;
49
+ } catch (err) {
50
+ res.writeHead(500, { "Content-Type": "application/json" });
51
+ res.end(JSON.stringify({ error: (err as Error).message }));
52
+ return;
53
+ }
54
+ res.writeHead(404, { "Content-Type": "application/json" });
55
+ res.end(JSON.stringify({ error: "Not found" }));
56
+ });
57
+ }
58
+
59
+ let server: Server;
60
+ const baseUrl = `http://localhost:${TEST_PORT}`;
61
+
62
+ beforeAll(async () => {
63
+ await removeDbFiles(TEST_DB_PATH);
64
+ initDb(TEST_DB_PATH);
65
+ server = makeTestServer();
66
+ await new Promise<void>((resolve) => {
67
+ server.listen(TEST_PORT, () => resolve());
68
+ });
69
+ });
70
+
71
+ afterAll(async () => {
72
+ await new Promise<void>((resolve) => {
73
+ server.close(() => resolve());
74
+ });
75
+ closeDb();
76
+ await removeDbFiles(TEST_DB_PATH);
77
+ });
78
+
79
+ beforeEach(() => {
80
+ getDb().prepare("DELETE FROM swarm_config").run();
81
+ getDb().prepare("DELETE FROM agents").run();
82
+ });
83
+
84
+ // ─── resolveHarnessProvider ──────────────────────────────────────────────────
85
+
86
+ describe("resolveHarnessProvider", () => {
87
+ test("returns 'claude' when neither env has HARNESS_PROVIDER", () => {
88
+ expect(resolveHarnessProvider({}, {})).toBe("claude");
89
+ });
90
+
91
+ test("returns the value from resolvedEnv (swarm_config overlay) when present", () => {
92
+ expect(
93
+ resolveHarnessProvider({ HARNESS_PROVIDER: "codex" }, { HARNESS_PROVIDER: "claude" }),
94
+ ).toBe("codex");
95
+ });
96
+
97
+ test("falls back to fallbackEnv when resolvedEnv lacks the key", () => {
98
+ expect(resolveHarnessProvider({}, { HARNESS_PROVIDER: "pi" })).toBe("pi");
99
+ });
100
+
101
+ test("ignores empty string in resolvedEnv and falls back", () => {
102
+ expect(resolveHarnessProvider({ HARNESS_PROVIDER: " " }, { HARNESS_PROVIDER: "codex" })).toBe(
103
+ "codex",
104
+ );
105
+ });
106
+
107
+ test("invalid value falls back to 'claude' (does not throw)", () => {
108
+ expect(resolveHarnessProvider({ HARNESS_PROVIDER: "not-a-provider" }, {})).toBe("claude");
109
+ });
110
+
111
+ test("trims whitespace before validating", () => {
112
+ expect(resolveHarnessProvider({ HARNESS_PROVIDER: " codex " }, {})).toBe("codex");
113
+ });
114
+ });
115
+
116
+ // ─── validateConfigValue ─────────────────────────────────────────────────────
117
+
118
+ describe("validateConfigValue", () => {
119
+ test("returns null for keys without a validator", () => {
120
+ expect(validateConfigValue("FOO_BAR", "anything")).toBeNull();
121
+ expect(validateConfigValue("MODEL_OVERRIDE", "sonnet")).toBeNull();
122
+ });
123
+
124
+ test("accepts a valid HARNESS_PROVIDER", () => {
125
+ expect(validateConfigValue("HARNESS_PROVIDER", "codex")).toBeNull();
126
+ expect(validateConfigValue("harness_provider", "claude")).toBeNull(); // case-insensitive
127
+ });
128
+
129
+ test("rejects an unknown HARNESS_PROVIDER with a helpful error", () => {
130
+ const err = validateConfigValue("HARNESS_PROVIDER", "claude-cod");
131
+ expect(err).not.toBeNull();
132
+ expect(err).toMatch(/HARNESS_PROVIDER/);
133
+ expect(err).toMatch(/claude/);
134
+ expect(err).toMatch(/codex/);
135
+ });
136
+
137
+ test("rejects non-string values for HARNESS_PROVIDER", () => {
138
+ expect(validateConfigValue("HARNESS_PROVIDER", 42)).not.toBeNull();
139
+ expect(validateConfigValue("HARNESS_PROVIDER", null)).not.toBeNull();
140
+ });
141
+ });
142
+
143
+ // ─── getResolvedConfig — scope precedence for HARNESS_PROVIDER ───────────────
144
+
145
+ describe("getResolvedConfig precedence for HARNESS_PROVIDER", () => {
146
+ test("agent scope wins over global scope", () => {
147
+ const a = createAgent({
148
+ name: "scope-test-1",
149
+ isLead: false,
150
+ status: "idle",
151
+ capabilities: [],
152
+ });
153
+
154
+ upsertSwarmConfig({ scope: "global", key: "HARNESS_PROVIDER", value: "claude" });
155
+ upsertSwarmConfig({
156
+ scope: "agent",
157
+ scopeId: a.id,
158
+ key: "HARNESS_PROVIDER",
159
+ value: "codex",
160
+ });
161
+
162
+ const resolved = getResolvedConfig(a.id);
163
+ const harness = resolved.find((c) => c.key === "HARNESS_PROVIDER");
164
+ expect(harness?.value).toBe("codex");
165
+ });
166
+
167
+ test("global scope applies when no agent-scoped row exists", () => {
168
+ const a = createAgent({
169
+ name: "scope-test-2",
170
+ isLead: false,
171
+ status: "idle",
172
+ capabilities: [],
173
+ });
174
+
175
+ upsertSwarmConfig({ scope: "global", key: "HARNESS_PROVIDER", value: "pi" });
176
+
177
+ const resolved = getResolvedConfig(a.id);
178
+ const harness = resolved.find((c) => c.key === "HARNESS_PROVIDER");
179
+ expect(harness?.value).toBe("pi");
180
+ });
181
+
182
+ test("nothing resolved when no rows exist (env fallback handled by runner)", () => {
183
+ const resolved = getResolvedConfig("agent-nonexistent");
184
+ expect(resolved.find((c) => c.key === "HARNESS_PROVIDER")).toBeUndefined();
185
+ });
186
+ });
187
+
188
+ // ─── PUT /api/config — guard rejects invalid HARNESS_PROVIDER ────────────────
189
+
190
+ describe("PUT /api/config rejects invalid HARNESS_PROVIDER", () => {
191
+ test("400 when value is not in ProviderNameSchema", async () => {
192
+ const res = await fetch(`${baseUrl}/api/config`, {
193
+ method: "PUT",
194
+ headers: { "Content-Type": "application/json" },
195
+ body: JSON.stringify({
196
+ scope: "global",
197
+ key: "HARNESS_PROVIDER",
198
+ value: "not-a-real-provider",
199
+ }),
200
+ });
201
+ expect(res.status).toBe(400);
202
+ const body = (await res.json()) as { error: string };
203
+ expect(body.error).toMatch(/HARNESS_PROVIDER/);
204
+ });
205
+
206
+ test("200 for a valid value, persists row", async () => {
207
+ const res = await fetch(`${baseUrl}/api/config`, {
208
+ method: "PUT",
209
+ headers: { "Content-Type": "application/json" },
210
+ body: JSON.stringify({
211
+ scope: "global",
212
+ key: "HARNESS_PROVIDER",
213
+ value: "codex",
214
+ }),
215
+ });
216
+ expect(res.status).toBe(200);
217
+
218
+ const rows = getResolvedConfig();
219
+ const harness = rows.find((c) => c.key === "HARNESS_PROVIDER");
220
+ expect(harness?.value).toBe("codex");
221
+ });
222
+
223
+ test("400 still rejects via PUT when scope=agent", async () => {
224
+ const a = createAgent({
225
+ name: "scope-test-3",
226
+ isLead: false,
227
+ status: "idle",
228
+ capabilities: [],
229
+ });
230
+ const res = await fetch(`${baseUrl}/api/config`, {
231
+ method: "PUT",
232
+ headers: { "Content-Type": "application/json" },
233
+ body: JSON.stringify({
234
+ scope: "agent",
235
+ scopeId: a.id,
236
+ key: "HARNESS_PROVIDER",
237
+ value: "claude-codex",
238
+ }),
239
+ });
240
+ expect(res.status).toBe(400);
241
+ });
242
+ });
@@ -1,4 +1,4 @@
1
- import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import { closeDb, completeTask, createAgent, getDb, getTaskById, initDb } from "../be/db";
4
4
  import { upsertOAuthApp } from "../be/db-queries/oauth";