@desplega.ai/agent-swarm 1.74.4 → 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 (88) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +1264 -46
  3. package/package.json +2 -2
  4. package/src/be/db.ts +563 -9
  5. package/src/be/memory/edges-store.ts +69 -0
  6. package/src/be/memory/providers/sqlite-store.ts +4 -0
  7. package/src/be/memory/raters/explicit-self.ts +22 -0
  8. package/src/be/memory/raters/implicit-citation.ts +44 -0
  9. package/src/be/memory/raters/llm-client.ts +172 -0
  10. package/src/be/memory/raters/llm-summarizer.ts +218 -0
  11. package/src/be/memory/raters/llm.ts +375 -0
  12. package/src/be/memory/raters/noop.ts +14 -0
  13. package/src/be/memory/raters/registry.ts +86 -0
  14. package/src/be/memory/raters/retrieval.ts +88 -0
  15. package/src/be/memory/raters/run-server-raters.ts +97 -0
  16. package/src/be/memory/raters/store.ts +228 -0
  17. package/src/be/memory/raters/types.ts +101 -0
  18. package/src/be/memory/reranker.ts +32 -2
  19. package/src/be/memory/retrieval-store.ts +116 -0
  20. package/src/be/memory/types.ts +3 -0
  21. package/src/be/migrations/051_memory_posteriors_and_retrieval.sql +67 -0
  22. package/src/be/migrations/052_memory_edges.sql +36 -0
  23. package/src/be/migrations/053_agent_waiting_for_credentials_status.sql +61 -0
  24. package/src/be/migrations/054_agent_harness_provider.sql +21 -0
  25. package/src/be/migrations/055_agent_cred_status.sql +15 -0
  26. package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
  27. package/src/be/migrations/057_inbox_item_state.sql +27 -0
  28. package/src/be/migrations/058_task_templates.sql +31 -0
  29. package/src/be/swarm-config-guard.ts +24 -0
  30. package/src/commands/credential-wait.ts +186 -0
  31. package/src/commands/provider-credentials.ts +434 -0
  32. package/src/commands/runner.ts +253 -21
  33. package/src/hooks/hook.ts +143 -66
  34. package/src/http/agents.ts +191 -1
  35. package/src/http/config.ts +11 -1
  36. package/src/http/core.ts +5 -0
  37. package/src/http/inbox-state.ts +89 -0
  38. package/src/http/index.ts +10 -0
  39. package/src/http/memory.ts +230 -1
  40. package/src/http/sessions.ts +86 -0
  41. package/src/http/status.ts +665 -0
  42. package/src/http/task-templates.ts +51 -0
  43. package/src/http/tasks.ts +85 -5
  44. package/src/http/users.ts +134 -0
  45. package/src/prompts/memories.ts +62 -0
  46. package/src/providers/claude-adapter.ts +22 -0
  47. package/src/providers/claude-managed-adapter.ts +24 -0
  48. package/src/providers/codex-adapter.ts +43 -1
  49. package/src/providers/devin-adapter.ts +18 -0
  50. package/src/providers/index.ts +7 -0
  51. package/src/providers/opencode-adapter.ts +60 -0
  52. package/src/providers/pi-mono-adapter.ts +71 -0
  53. package/src/providers/types.ts +34 -0
  54. package/src/server.ts +2 -0
  55. package/src/slack/handlers.ts +0 -1
  56. package/src/tests/agents-harness-provider.test.ts +333 -0
  57. package/src/tests/credential-check.test.ts +367 -0
  58. package/src/tests/credential-status-api.test.ts +223 -0
  59. package/src/tests/credential-status-routing.test.ts +150 -0
  60. package/src/tests/credential-wait.test.ts +282 -0
  61. package/src/tests/harness-provider-resolution.test.ts +242 -0
  62. package/src/tests/jira-sync.test.ts +1 -1
  63. package/src/tests/memory-edges.test.ts +722 -0
  64. package/src/tests/memory-rate-endpoint.test.ts +330 -0
  65. package/src/tests/memory-rate-tool.test.ts +252 -0
  66. package/src/tests/memory-rater-e2e.test.ts +578 -0
  67. package/src/tests/memory-rater-implicit-citation.test.ts +304 -0
  68. package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
  69. package/src/tests/memory-rater-llm.test.ts +964 -0
  70. package/src/tests/memory-rater-store.test.ts +249 -0
  71. package/src/tests/memory-reranker.test.ts +161 -2
  72. package/src/tests/migration-runner-regressions.test.ts +17 -2
  73. package/src/tests/mocks/mock-llm-rater-client.ts +35 -0
  74. package/src/tests/run-server-raters.test.ts +291 -0
  75. package/src/tests/sessions.test.ts +141 -0
  76. package/src/tests/status.test.ts +843 -0
  77. package/src/tests/stop-hook-task-resolution.test.ts +98 -0
  78. package/src/tests/template-recommendations.test.ts +148 -0
  79. package/src/tests/tool-annotations.test.ts +2 -2
  80. package/src/tests/use-dismissible-card.test.ts +140 -0
  81. package/src/tools/memory-rate.ts +166 -0
  82. package/src/tools/memory-search.ts +18 -0
  83. package/src/tools/store-progress.ts +37 -0
  84. package/src/tools/swarm-config/set-config.ts +17 -1
  85. package/src/tools/tool-config.ts +1 -0
  86. package/src/types.ts +122 -1
  87. package/src/utils/harness-provider.ts +32 -0
  88. package/tsconfig.json +0 -2
@@ -28,6 +28,8 @@ import { createSwarmHooksExtension } from "./pi-mono-extension";
28
28
  import { McpHttpClient } from "./pi-mono-mcp-client";
29
29
  import type {
30
30
  CostData,
31
+ CredCheckOptions,
32
+ CredStatus,
31
33
  ProviderAdapter,
32
34
  ProviderEvent,
33
35
  ProviderResult,
@@ -35,6 +37,75 @@ import type {
35
37
  ProviderSessionConfig,
36
38
  } from "./types";
37
39
 
40
+ /**
41
+ * Map a `MODEL_OVERRIDE` string to the env var that satisfies it. Mirrors
42
+ * `resolveModel` above (shortname → anthropic, `provider/model-id` → that
43
+ * provider). Returns `null` when the override is empty (boot-loop should
44
+ * treat it as the permissive case) or the provider can't be inferred.
45
+ */
46
+ function modelToCredKey(modelStr: string | undefined): string | null {
47
+ if (!modelStr) return null;
48
+ const lower = modelStr.toLowerCase();
49
+ // Hard-coded shortnames map straight to anthropic.
50
+ if (lower === "opus" || lower === "sonnet" || lower === "haiku") {
51
+ return "ANTHROPIC_API_KEY";
52
+ }
53
+ if (modelStr.includes("/")) {
54
+ const provider = modelStr.slice(0, modelStr.indexOf("/")).toLowerCase();
55
+ if (provider === "anthropic") return "ANTHROPIC_API_KEY";
56
+ if (provider === "openrouter") return "OPENROUTER_API_KEY";
57
+ if (provider === "openai") return "OPENAI_API_KEY";
58
+ if (provider === "google") return "GOOGLE_API_KEY";
59
+ }
60
+ // Bare model name with no provider prefix — adapter falls through to a
61
+ // best-effort resolution against multiple providers, so the boot loop
62
+ // accepts any one of them.
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Pi-mono is satisfied by ANY of:
68
+ * 1. `~/.pi/agent/auth.json` exists.
69
+ * 2. `MODEL_OVERRIDE` is set to a provider-prefixed model — only the
70
+ * matching provider's key is required.
71
+ * 3. `MODEL_OVERRIDE` is empty / unprefixed — any one of the supported
72
+ * keys (ANTHROPIC_API_KEY / OPENROUTER_API_KEY / OPENAI_API_KEY) is
73
+ * enough.
74
+ */
75
+ export function checkPiMonoCredentials(
76
+ env: Record<string, string | undefined>,
77
+ opts: CredCheckOptions = {},
78
+ ): CredStatus {
79
+ const homeDir = opts.homeDir ?? env.HOME ?? "/root";
80
+ const probe = opts.fs?.existsSync ?? existsSync;
81
+ const authFile = `${homeDir}/.pi/agent/auth.json`;
82
+ if (probe(authFile)) {
83
+ return { ready: true, missing: [], satisfiedBy: "file" };
84
+ }
85
+
86
+ const requiredKey = modelToCredKey(env.MODEL_OVERRIDE);
87
+ if (requiredKey) {
88
+ if (env[requiredKey]) {
89
+ return { ready: true, missing: [], satisfiedBy: "env" };
90
+ }
91
+ return {
92
+ ready: false,
93
+ missing: [requiredKey, authFile],
94
+ hint: `MODEL_OVERRIDE=${env.MODEL_OVERRIDE} requires ${requiredKey}; or run \`pi auth login\` to create ${authFile}.`,
95
+ };
96
+ }
97
+
98
+ // Permissive case: any one supported key works.
99
+ if (env.ANTHROPIC_API_KEY || env.OPENROUTER_API_KEY || env.OPENAI_API_KEY) {
100
+ return { ready: true, missing: [], satisfiedBy: "env" };
101
+ }
102
+ return {
103
+ ready: false,
104
+ missing: ["ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY", authFile],
105
+ hint: "Set one of ANTHROPIC_API_KEY / OPENROUTER_API_KEY / OPENAI_API_KEY (any one suffices), or run `pi auth login` to create ~/.pi/agent/auth.json.",
106
+ };
107
+ }
108
+
38
109
  /** Convert a JSON Schema object to a TypeBox TSchema using Type.Unsafe */
39
110
  function jsonSchemaToTypeBox(schema: Record<string, unknown>): TSchema {
40
111
  // Type.Unsafe wraps a plain JSON Schema as a TypeBox-compatible TSchema
@@ -111,3 +111,37 @@ export interface ProviderAdapter {
111
111
  canResume(sessionId: string): Promise<boolean>;
112
112
  formatCommand(commandName: string): string;
113
113
  }
114
+
115
+ /**
116
+ * Status returned by per-adapter `checkCredentials(env, opts)` predicates.
117
+ *
118
+ * `ready=false` means the worker should park in the credential-wait loop
119
+ * (Phase 2). `missing` lists env-var names (or absolute file paths) the
120
+ * adapter would accept; the dashboard surfaces this list as the "blocked
121
+ * on …" hint.
122
+ *
123
+ * `satisfiedBy`:
124
+ * - `'env'` — env-var(s) directly satisfy the adapter
125
+ * - `'file'` — an existing on-disk auth.json was found
126
+ * - `'side-effect-pending'` — env-vars are present but a follow-up step
127
+ * (e.g. `codex login --with-api-key`) still needs to run before the
128
+ * adapter can use them. Workers should treat this as "ready" for the
129
+ * purposes of the boot loop — the side-effect is the entrypoint's job.
130
+ */
131
+ export interface CredStatus {
132
+ ready: boolean;
133
+ missing: string[];
134
+ satisfiedBy?: "env" | "file" | "side-effect-pending";
135
+ hint?: string;
136
+ }
137
+
138
+ /**
139
+ * Options threaded into `checkCredentials` for testability — the codex and
140
+ * pi/opencode predicates probe the filesystem for `~/.codex/auth.json`,
141
+ * `~/.pi/agent/auth.json`, `~/.local/share/opencode/auth.json`. Tests inject
142
+ * a fake `fs` + `homeDir` to exercise the file-vs-env branches deterministically.
143
+ */
144
+ export interface CredCheckOptions {
145
+ homeDir?: string;
146
+ fs?: { existsSync(p: string): boolean };
147
+ }
package/src/server.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  // Memory capability
30
30
  import { registerMemoryDeleteTool } from "./tools/memory-delete";
31
31
  import { registerMemoryGetTool } from "./tools/memory-get";
32
+ import { registerMemoryRateTool } from "./tools/memory-rate";
32
33
  import { registerMemorySearchTool } from "./tools/memory-search";
33
34
  import { registerMyAgentInfoTool } from "./tools/my-agent-info";
34
35
  import { registerPollTaskTool } from "./tools/poll-task";
@@ -241,6 +242,7 @@ export function createServer() {
241
242
  registerMemorySearchTool(server);
242
243
  registerMemoryGetTool(server);
243
244
  registerMemoryDeleteTool(server);
245
+ registerMemoryRateTool(server);
244
246
  registerInjectLearningTool(server);
245
247
  }
246
248
 
@@ -1,7 +1,6 @@
1
1
  import type { App } from "@slack/bolt";
2
2
  import type { WebClient } from "@slack/web-api";
3
3
  import {
4
- createTaskExtended,
5
4
  getAgentById,
6
5
  getAgentWorkingOnThread,
7
6
  getLeadAgent,
@@ -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
+ });