@desplega.ai/agent-swarm 2.0.0 → 2.1.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 (238) hide show
  1. package/dist/{acp-adapter-sj2g0m84.js → acp-adapter-kr4abrsk.js} +10 -10
  2. package/dist/{actions-fegk44gr.js → actions-eh4g6w2r.js} +27 -27
  3. package/dist/{amp-adapter-ad8ft89x.js → amp-adapter-7h7wdt8q.js} +5 -5
  4. package/dist/{app-kfvgbvzy.js → app-7k8s9w5p.js} +9 -9
  5. package/dist/{assistant-z7zdy9ja.js → assistant-wswt2y7p.js} +28 -28
  6. package/dist/{boot-reembed-j8xa181e.js → boot-reembed-9apm9qw6.js} +7 -7
  7. package/dist/{boot-reembed-5c2z0mbe.js → boot-reembed-whzbdthj.js} +7 -7
  8. package/dist/{boot-scrub-sweep-hnkn3rgt.js → boot-scrub-sweep-ndzmvmgt.js} +8 -8
  9. package/dist/{boot-seeds-kwjq4d2z.js → boot-seeds-e9sta0yt.js} +13 -11
  10. package/dist/{claude-adapter-9vmvpvgd.js → claude-adapter-4mqd3r6r.js} +11 -11
  11. package/dist/{claude-managed-adapter-k9qae05j.js → claude-managed-adapter-9wswb52m.js} +3 -3
  12. package/dist/{claude-managed-setup-q54zx5q2.js → claude-managed-setup-tpe1effx.js} +4 -4
  13. package/dist/{claude-sdk-session-9jye6cxr.js → claude-sdk-session-q49ghmh9.js} +10340 -10197
  14. package/dist/{cli-ke896fcf.js → cli-01xvj1rq.js} +10 -10
  15. package/dist/{cli-tr4sxp68.js → cli-035vekcw.js} +2 -2
  16. package/dist/{cli-z5jjpaxw.js → cli-0pd8hzhc.js} +3 -3
  17. package/dist/{cli-n5vwg842.js → cli-0veb92a3.js} +6 -1
  18. package/dist/{cli-wayw93nt.js → cli-10jesr25.js} +3 -3
  19. package/dist/{cli-se8w6wny.js → cli-1y7yywmj.js} +1 -1
  20. package/dist/{cli-v1yb0rqb.js → cli-20krdhz5.js} +47 -19
  21. package/dist/{cli-d5f9ed5c.js → cli-29rja109.js} +39 -20
  22. package/dist/{cli-apx87jne.js → cli-2pwa0q4d.js} +15 -4
  23. package/dist/{cli-ee9gtz70.js → cli-4mengw46.js} +348 -25
  24. package/dist/{cli-03gedhng.js → cli-5twdke5x.js} +6 -6
  25. package/dist/{cli-6razj6t4.js → cli-711vwbxn.js} +2 -2
  26. package/dist/{cli-s2sp957d.js → cli-78vv7446.js} +1 -1
  27. package/dist/{cli-3fbmmawm.js → cli-7n6s5man.js} +4 -4
  28. package/dist/{cli-hnhtk6cj.js → cli-8wwxw8a3.js} +1 -1
  29. package/dist/{cli-hnmhtzvh.js → cli-9jzdj90z.js} +2 -0
  30. package/dist/{cli-skxvvpap.js → cli-cvv9k0hh.js} +28 -25
  31. package/dist/{cli-74ahz61x.js → cli-d9286m9h.js} +2 -2
  32. package/dist/{cli-4jxm2qtz.js → cli-gxzkg1de.js} +28 -16
  33. package/dist/{cli-zkpks3r3.js → cli-h1arqn31.js} +1 -1
  34. package/dist/{cli-r62a35ea.js → cli-hqr88ew6.js} +1 -1
  35. package/dist/{cli-ra0xzggj.js → cli-j67mh2vg.js} +2 -2
  36. package/dist/{cli-29jc5q7w.js → cli-ksphgh48.js} +2 -2
  37. package/dist/{cli-eejsgvm4.js → cli-mgz4nbgg.js} +4 -4
  38. package/dist/{cli-vatyhshy.js → cli-mz5as5ha.js} +4 -4
  39. package/dist/{cli-f7kr7svz.js → cli-n8ndw8j7.js} +2 -2
  40. package/dist/{cli-1e4b6bzh.js → cli-naqzr17j.js} +7 -7
  41. package/dist/{cli-q0ke1yww.js → cli-nm1p8jfr.js} +6 -6
  42. package/dist/{cli-m50t4309.js → cli-p3zbc1rw.js} +5 -5
  43. package/dist/{cli-cgtksawx.js → cli-pe05f8yk.js} +23 -3
  44. package/dist/{cli-vdach5a9.js → cli-qmzsbkvy.js} +4 -4
  45. package/dist/{cli-n3hzg89b.js → cli-qnm4e9e4.js} +57 -579
  46. package/dist/{cli-s4setq91.js → cli-qqy4q48b.js} +3 -3
  47. package/dist/{cli-k382tshb.js → cli-r35rw5de.js} +8 -7
  48. package/dist/{cli-k3dp5tpf.js → cli-r610p9bs.js} +2 -2
  49. package/dist/{cli-e6mpjhed.js → cli-rc6pf5kv.js} +1 -1
  50. package/dist/{cli-3dj8k4vy.js → cli-rdxm77k3.js} +1 -1
  51. package/dist/{cli-tqaazemc.js → cli-s69nfyeh.js} +1 -1
  52. package/dist/{cli-6jyj59nb.js → cli-sawv63e8.js} +2 -2
  53. package/dist/{cli-q50942rs.js → cli-sg19gbf0.js} +1 -1
  54. package/dist/{cli-p1x85d2t.js → cli-shk7r5cs.js} +12 -9
  55. package/dist/{cli-przpmdgr.js → cli-sksetb2q.js} +11 -1
  56. package/dist/{cli-d2qr7aj4.js → cli-t02z59f2.js} +1 -1
  57. package/dist/{cli-j0pfptdp.js → cli-v7g551te.js} +39 -43
  58. package/dist/{cli-bzszjf2p.js → cli-vgqzp4em.js} +519 -126
  59. package/dist/cli-wfnemr1t.js +86 -0
  60. package/dist/{cli-ne236afy.js → cli-wms6x9db.js} +8 -8
  61. package/dist/{cli-5hkm7whv.js → cli-x1r545x8.js} +1 -1
  62. package/dist/{cli-kt6zqg0e.js → cli-xczsgshn.js} +2 -2
  63. package/dist/{cli-ef1hg1wv.js → cli-y3167wt1.js} +430 -238
  64. package/dist/{cli-tsde1hdr.js → cli-yjmqpxt3.js} +12 -11
  65. package/dist/{cli-9nfx44eh.js → cli-z780ec7c.js} +1 -1
  66. package/dist/{cli-r2pnvj8g.js → cli-zm9t2vde.js} +2 -2
  67. package/dist/cli.js +24 -23
  68. package/dist/{codex-adapter-y81sssk0.js → codex-adapter-4z16v9n0.js} +8 -8
  69. package/dist/{codex-login-f2rcfpp9.js → codex-login-pkepy2j1.js} +2 -2
  70. package/dist/{codex-session-runner-8vx4j6a9.js → codex-session-runner-rvms9y2x.js} +8 -8
  71. package/dist/{commands-kcpvpcrr.js → commands-2a9rtyat.js} +8 -8
  72. package/dist/{controller-ywrmt6yd.js → controller-3k3madbn.js} +4 -4
  73. package/dist/{controllers-connect-kwpvj3rf.js → controllers-connect-v9yn8zb9.js} +30 -30
  74. package/dist/{cursor-adapter-cehv1qns.js → cursor-adapter-98gbhyzn.js} +4 -4
  75. package/dist/{db-ww5d7jq3.js → db-eywwsnjw.js} +9 -7
  76. package/dist/{db-k8ankj20.js → db-ncypk9np.js} +9 -9
  77. package/dist/{devin-adapter-x1022qb1.js → devin-adapter-zeq8b9gz.js} +2 -2
  78. package/dist/{dispatcher-e8gq5m68.js → dispatcher-w94kc575.js} +26 -26
  79. package/dist/{docker-ff6y1190.js → docker-b1e4cqab.js} +2 -2
  80. package/dist/{dsh-adapter-3epjm3af.js → dsh-adapter-47ztej36.js} +4 -4
  81. package/dist/{e2b-8ygwhg0f.js → e2b-bbt8ey45.js} +3 -3
  82. package/dist/{e2b-swhnpw3n.js → e2b-dp804sen.js} +3 -3
  83. package/dist/{event-bus-6axnngdj.js → event-bus-j2jdg3qb.js} +2 -2
  84. package/dist/{external-channel-h3359cwn.js → external-channel-zrkg8ar9.js} +8 -8
  85. package/dist/{grok-adapter-0d4x7gkx.js → grok-adapter-s9ncp9vy.js} +11 -11
  86. package/dist/{handlers-an1gzv21.js → handlers-kqftvjgw.js} +29 -29
  87. package/dist/{heartbeat-6dakeb61.js → heartbeat-9q96p8v9.js} +31 -31
  88. package/dist/{hook-6v6cv6d5.js → hook-56qe10dp.js} +7 -7
  89. package/dist/{hook-sa5xye4y.js → hook-yy6rw42k.js} +8 -8
  90. package/dist/{http-r7f3eky0.js → http-r2dnb8f8.js} +963 -75
  91. package/dist/{index-7ep4gzdk.js → index-3gtmkhc6.js} +26 -26
  92. package/dist/{index-v1v6n7hg.js → index-3k5hkz7r.js} +31 -31
  93. package/dist/{index-rmpw9a8q.js → index-5e101240.js} +26 -26
  94. package/dist/{index-s2wxgzr0.js → index-5wyfjc2c.js} +392 -15
  95. package/dist/{index-zk96aq1k.js → index-vxvhxxbn.js} +7 -7
  96. package/dist/{keepalive-be9vwy5h.js → keepalive-psj6j54c.js} +9 -9
  97. package/dist/{lead-0861cwrf.js → lead-hajz4688.js} +43 -43
  98. package/dist/{maintenance-mzzgc8bn.js → maintenance-ps02sb5m.js} +7 -7
  99. package/dist/{node-mn1zs7an.js → node-mwb5r697.js} +1 -1
  100. package/dist/{oauth-refresh-sweep-cpdnfwdj.js → oauth-refresh-sweep-0284h93b.js} +8 -8
  101. package/dist/{onboard-fh8qs4r3.js → onboard-rbf6bg01.js} +2 -2
  102. package/dist/{opencode-adapter-nm42wwj8.js → opencode-adapter-13dcatgq.js} +8 -8
  103. package/dist/{otel-impl-x6c1c9g4.js → otel-impl-zvqcbhp7.js} +2 -2
  104. package/dist/{pi-mono-adapter-9n2ya240.js → pi-mono-adapter-xxy47q4v.js} +7 -7
  105. package/dist/{pricing-refresh-qb0rv0ap.js → pricing-refresh-kefx9v3t.js} +9 -9
  106. package/dist/{rbac-roles-1gzdwbh1.js → rbac-roles-3m36rxm2.js} +8 -8
  107. package/dist/{rbac-roles-3mraey52.js → rbac-roles-z76xv0hq.js} +9 -9
  108. package/dist/{render-v2-agpa45zz.js → render-v2-wz73bs25.js} +9 -9
  109. package/dist/{resume-x0ydy9s9.js → resume-k4wzge8w.js} +14 -14
  110. package/dist/{setup-as9ga0d6.js → setup-2fp2pypp.js} +2 -2
  111. package/dist/{work-objects-y5g1y7ev.js → work-objects-6k0ywz3m.js} +29 -29
  112. package/dist/{worker-860c3e38.js → worker-5v3rxsdh.js} +43 -43
  113. package/dist/{x-m477fr0h.js → x-4mtdeym1.js} +2 -2
  114. package/openapi.json +777 -6
  115. package/package.json +8 -7
  116. package/src/agentmail/handlers.ts +21 -11
  117. package/src/apps/definition.ts +16 -0
  118. package/src/azure-devops/api.ts +52 -2
  119. package/src/azure-devops/boards-sync.ts +50 -20
  120. package/src/azure-devops/index.ts +1 -0
  121. package/src/azure-devops/outbound.ts +95 -0
  122. package/src/be/boot-seeds.ts +11 -0
  123. package/src/be/db/model-profiles.ts +315 -0
  124. package/src/be/db/tasks/read.ts +7 -0
  125. package/src/be/db.ts +53 -47
  126. package/src/be/migrations/219_relabel_pi_context_compactions.sql +22 -0
  127. package/src/be/migrations/220_model_profiles.sql +137 -0
  128. package/src/be/model-tier-resolution.ts +145 -11
  129. package/src/be/model-validation.ts +8 -1
  130. package/src/be/scripts/typecheck.ts +12 -0
  131. package/src/be/secret-registry.ts +6 -2
  132. package/src/be/seed-model-profiles.ts +41 -0
  133. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +368 -0
  134. package/src/be/seed-scripts/index.ts +9 -0
  135. package/src/be/swarm-config-guard.ts +1 -0
  136. package/src/commands/runner.ts +3 -17
  137. package/src/extensions/catalog.generated.json +17 -0
  138. package/src/extensions/contract-types.generated.ts +1 -1
  139. package/src/extensions/loader.ts +10 -5
  140. package/src/heartbeat/heartbeat.ts +2 -1
  141. package/src/http/agents.ts +49 -2
  142. package/src/http/all-routes.ts +1 -0
  143. package/src/http/apps.ts +30 -3
  144. package/src/http/config.ts +26 -2
  145. package/src/http/core.ts +4 -0
  146. package/src/http/index.ts +11 -1
  147. package/src/http/model-profiles.ts +303 -0
  148. package/src/http/pages-public.ts +11 -5
  149. package/src/http/poll.ts +16 -5
  150. package/src/http/script-runs.ts +1 -1
  151. package/src/http/scripts.ts +25 -2
  152. package/src/http/tasks.ts +18 -0
  153. package/src/http/webhooks.ts +1 -2
  154. package/src/pages/feedback-overlay.ts +65 -6
  155. package/src/providers/acp-targets.ts +1 -1
  156. package/src/providers/amp-adapter.ts +1 -1
  157. package/src/providers/dsh-adapter.ts +1 -1
  158. package/src/providers/pi-mono-extension.ts +17 -3
  159. package/src/providers/reasoning-effort.ts +10 -0
  160. package/src/rbac/legacy-policy.ts +8 -0
  161. package/src/rbac/permissions.ts +5 -0
  162. package/src/scheduler/deferred-task-waits.ts +18 -5
  163. package/src/scheduler/scheduler.ts +7 -1
  164. package/src/scripts-runtime/executors/quickjs-prelude.ts +75 -6
  165. package/src/scripts-runtime/executors/quickjs-runner.ts +85 -8
  166. package/src/scripts-runtime/executors/quickjs-web-shims.ts +1041 -0
  167. package/src/scripts-runtime/extract-args-schema.ts +9 -8
  168. package/src/scripts-runtime/quickjs-compat.ts +464 -0
  169. package/src/scripts-runtime/sdk-allowlist.ts +4 -0
  170. package/src/scripts-runtime/types/stdlib.d.ts +12 -0
  171. package/src/scripts-runtime/types/swarm-extension.d.ts +3 -0
  172. package/src/scripts-runtime/types/swarm-sdk.d.ts +12 -0
  173. package/src/server.ts +10 -1
  174. package/src/slack/blocks.ts +4 -2
  175. package/src/slack/reaction-shortcode.ts +14 -0
  176. package/src/slack/render-v2.ts +12 -7
  177. package/src/slack/responses.ts +5 -4
  178. package/src/slack/thread-buffer.ts +41 -22
  179. package/src/tasks/worker-follow-up.ts +11 -2
  180. package/src/tests/agentmail-filters.test.ts +47 -0
  181. package/src/tests/apps-spike2.test.ts +35 -0
  182. package/src/tests/azure-devops-outbound.test.ts +420 -0
  183. package/src/tests/db-task-read-contract.test.ts +2 -0
  184. package/src/tests/db-task-write-contract.test.ts +1 -0
  185. package/src/tests/deferred-task-wake.test.ts +78 -1
  186. package/src/tests/docs-index-footprint.test.js +2 -2
  187. package/src/tests/dsh-adapter.test.ts +1 -1
  188. package/src/tests/env-flag.test.ts +1 -0
  189. package/src/tests/extensions-catalog.test.ts +1 -0
  190. package/src/tests/extensions-example-comms-adherence-gate.test.ts +413 -0
  191. package/src/tests/extensions-loader.test.ts +15 -0
  192. package/src/tests/heartbeat-jev-gate-credentials.test.ts +168 -0
  193. package/src/tests/heartbeat-jev-gate.test.ts +385 -0
  194. package/src/tests/model-profile-backcompat.test.ts +327 -0
  195. package/src/tests/model-profile-providers.test.ts +302 -0
  196. package/src/tests/model-profile-runtime-secrets.test.ts +223 -0
  197. package/src/tests/model-profiles-api.test.ts +614 -0
  198. package/src/tests/pages-feedback-keys.test.ts +121 -0
  199. package/src/tests/pages-feedback.test.ts +3 -0
  200. package/src/tests/pi-context-compaction.test.ts +193 -0
  201. package/src/tests/profile-claim.test.ts +269 -0
  202. package/src/tests/quickjs-compat.test.ts +213 -0
  203. package/src/tests/rbac-engine.test.ts +21 -0
  204. package/src/tests/script-connections.test.ts +4 -0
  205. package/src/tests/script-executor-conformance.test.ts +252 -1
  206. package/src/tests/script-executor-quickjs.test.ts +96 -0
  207. package/src/tests/script-runs-http.test.ts +89 -0
  208. package/src/tests/scripts-http.test.ts +16 -0
  209. package/src/tests/secret-scrubber.test.ts +10 -0
  210. package/src/tests/seed-model-profiles.test.ts +174 -0
  211. package/src/tests/seed-scripts.test.ts +2 -2
  212. package/src/tests/skill-invocations.test.ts +4 -2
  213. package/src/tests/slack-blocks.test.ts +36 -0
  214. package/src/tests/slack-inline-output.test.ts +14 -0
  215. package/src/tests/slack-reaction-config.test.ts +20 -0
  216. package/src/tests/slack-render-v2.test.ts +37 -0
  217. package/src/tests/slack-thread-buffer.test.ts +98 -1
  218. package/src/tests/workflow-llm.test.ts +61 -0
  219. package/src/tools/model-profiles/list-model-profiles.ts +54 -0
  220. package/src/tools/request-human-input.ts +11 -3
  221. package/src/tools/script-apis.ts +5 -1
  222. package/src/tools/script-upsert.ts +4 -1
  223. package/src/tools/send-task.ts +24 -1
  224. package/src/tools/system-one-decide.ts +104 -0
  225. package/src/tools/task-action.ts +22 -1
  226. package/src/tools/tool-config.ts +6 -0
  227. package/src/types.ts +36 -1
  228. package/src/utils/secret-scrubber.ts +19 -12
  229. package/src/utils/vcs-repo-match.ts +29 -0
  230. package/src/workflows/executors/raw-llm.ts +8 -3
  231. package/src/workflows/executors/system-one-decision.ts +21 -11
  232. package/src/workflows/executors/workflow-llm.ts +22 -0
  233. package/src/workflows/index.ts +21 -12
  234. package/templates/extensions/README.md +1 -0
  235. package/templates/extensions/comms-adherence-gate/README.md +88 -0
  236. package/templates/extensions/comms-adherence-gate/hooks.ts +489 -0
  237. package/templates/extensions/comms-adherence-gate/manifest.json +8 -0
  238. package/dist/cli-5t4f29ez.js +0 -33
@@ -0,0 +1,614 @@
1
+ // Model profiles over HTTP: CRUD and its RBAC gate, the agent default set through
2
+ // PATCH /api/agents/{id}/runtime, and the harness check on task creation.
3
+
4
+ import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
5
+ import { unlink } from "node:fs/promises";
6
+ import { createServer, type Server } from "node:http";
7
+ import {
8
+ closeDb,
9
+ createAgent,
10
+ createTaskExtended,
11
+ getDbClient,
12
+ getTaskById,
13
+ initDb,
14
+ startTask,
15
+ supersedeTask,
16
+ upsertSwarmConfig,
17
+ } from "../be/db";
18
+ import * as profilesDb from "../be/db/model-profiles";
19
+ import { createModelProfile, taskProfileError } from "../be/db/model-profiles";
20
+ import { codeLevelTriage } from "../heartbeat/heartbeat";
21
+ import { handleAgentsRest } from "../http/agents";
22
+ import { handleModelProfiles } from "../http/model-profiles";
23
+ import { handleTasks } from "../http/tasks";
24
+ import * as followUps from "../tasks/worker-follow-up";
25
+ import type { User } from "../types";
26
+ import { setRequestAuth } from "../utils/request-auth-context";
27
+ import { listenOnFreePort } from "./test-net";
28
+
29
+ const TEST_DB_PATH = "./test-model-profiles-api.sqlite";
30
+ const LEAD_ID = "11111111-1111-4111-8111-111111111111";
31
+ const CLAUDE_ID = "22222222-2222-4222-8222-222222222222";
32
+ const CODEX_ID = "44444444-4444-4444-8444-444444444444";
33
+ const USER_ID = "33333333-3333-4333-8333-333333333333";
34
+
35
+ async function removeDbFiles(path: string): Promise<void> {
36
+ for (const suffix of ["", "-wal", "-shm"]) {
37
+ try {
38
+ await unlink(path + suffix);
39
+ } catch (error) {
40
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
41
+ }
42
+ }
43
+ }
44
+
45
+ let server: Server;
46
+ let baseUrl = "";
47
+
48
+ beforeAll(async () => {
49
+ await removeDbFiles(TEST_DB_PATH);
50
+ initDb(TEST_DB_PATH);
51
+ await getDbClient().run(
52
+ "INSERT INTO users (id, name, createdAt, lastUpdatedAt) VALUES (?, 'Profile User', '2026-01-01', '2026-01-01')",
53
+ [USER_ID],
54
+ );
55
+ await createAgent({ id: LEAD_ID, name: "mp-lead", isLead: true, status: "idle" });
56
+ await createAgent({
57
+ id: CLAUDE_ID,
58
+ name: "mp-claude",
59
+ isLead: false,
60
+ status: "idle",
61
+ harnessProvider: "claude",
62
+ });
63
+ await createAgent({
64
+ id: CODEX_ID,
65
+ name: "mp-codex",
66
+ isLead: false,
67
+ status: "idle",
68
+ harnessProvider: "codex",
69
+ });
70
+
71
+ server = createServer((req, res) => {
72
+ const kind = req.headers["x-test-auth"];
73
+ if (kind === "operator") setRequestAuth(req, { kind: "operator", fingerprint: "test" });
74
+ if (kind === "user") {
75
+ setRequestAuth(req, { kind: "user", userId: USER_ID, user: { id: USER_ID } as User });
76
+ }
77
+ const header = req.headers["x-agent-id"];
78
+ const agentId = Array.isArray(header) ? header[0] : header;
79
+ const url = new URL(req.url ?? "/", "http://localhost");
80
+ const segments = url.pathname.split("/").filter(Boolean);
81
+ void (async () => {
82
+ for (const handle of [handleModelProfiles, handleAgentsRest, handleTasks]) {
83
+ if (await handle(req, res, segments, url.searchParams, agentId)) return;
84
+ }
85
+ res.statusCode = 404;
86
+ res.end("{}");
87
+ })();
88
+ });
89
+ baseUrl = `http://localhost:${await listenOnFreePort(server)}`;
90
+ });
91
+
92
+ afterAll(async () => {
93
+ server.close();
94
+ closeDb();
95
+ await removeDbFiles(TEST_DB_PATH);
96
+ });
97
+
98
+ beforeEach(async () => {
99
+ // References first: migration 220's triggers refuse deleting a named profile.
100
+ await getDbClient().run("DELETE FROM agent_tasks");
101
+ await getDbClient().run("DELETE FROM swarm_config WHERE key = 'MODEL_PROFILE'");
102
+ await getDbClient().run("DELETE FROM model_profiles");
103
+ });
104
+
105
+ function call(
106
+ method: string,
107
+ path: string,
108
+ body: unknown,
109
+ headers: Record<string, string> = { "x-test-auth": "operator" },
110
+ ): Promise<Response> {
111
+ return fetch(`${baseUrl}${path}`, {
112
+ method,
113
+ headers: { "content-type": "application/json", ...headers },
114
+ body: body === undefined ? undefined : JSON.stringify(body),
115
+ });
116
+ }
117
+
118
+ async function modelProfileConfig(agentId: string) {
119
+ return await getDbClient().get<{ value: string; exportToEnv: number }>(
120
+ "SELECT value, exportToEnv FROM swarm_config WHERE scope = 'agent' AND scopeId = ? AND key = 'MODEL_PROFILE'",
121
+ [agentId],
122
+ );
123
+ }
124
+
125
+ describe("profile CRUD", () => {
126
+ test("operator creates, reads, lists, updates and deletes a profile", async () => {
127
+ const created = await call("POST", "/api/model-profiles", {
128
+ name: "claude-gateway-high",
129
+ harness: "claude",
130
+ model: "my-gateway-model",
131
+ effort: null,
132
+ allowCustomModel: true,
133
+ description: "Gateway model",
134
+ });
135
+ expect(created.status).toBe(201);
136
+ expect(await created.json()).toMatchObject({
137
+ name: "claude-gateway-high",
138
+ harness: "claude",
139
+ model: "my-gateway-model",
140
+ effort: null,
141
+ allowCustomModel: true,
142
+ seededFrom: null,
143
+ });
144
+
145
+ const listed = await call("GET", "/api/model-profiles?harness=claude", undefined);
146
+ const { profiles } = (await listed.json()) as { profiles: { name: string }[] };
147
+ expect(profiles.map((p) => p.name)).toEqual(["claude-gateway-high"]);
148
+ const codexOnly = await call("GET", "/api/model-profiles?harness=codex", undefined);
149
+ expect(((await codexOnly.json()) as { profiles: unknown[] }).profiles).toEqual([]);
150
+
151
+ const patched = await call("PATCH", "/api/model-profiles/claude-gateway-high", {
152
+ model: "my-gateway-model-2",
153
+ description: null,
154
+ });
155
+ expect(patched.status).toBe(200);
156
+ expect(await patched.json()).toMatchObject({ model: "my-gateway-model-2", description: null });
157
+
158
+ const deleted = await call("DELETE", "/api/model-profiles/claude-gateway-high", undefined);
159
+ expect(deleted.status).toBe(200);
160
+ expect((await call("GET", "/api/model-profiles/claude-gateway-high", undefined)).status).toBe(
161
+ 404,
162
+ );
163
+ });
164
+
165
+ test("only the operator may write; dashboard users and agents, the lead included, may not", async () => {
166
+ const body = { name: "devin-any", harness: "devin", model: "devin" };
167
+ expect(
168
+ (await call("POST", "/api/model-profiles", body, { "x-agent-id": LEAD_ID })).status,
169
+ ).toBe(403);
170
+ expect(
171
+ (await call("POST", "/api/model-profiles", body, { "x-agent-id": CLAUDE_ID })).status,
172
+ ).toBe(403);
173
+ expect(
174
+ (await call("POST", "/api/model-profiles", body, { "x-test-auth": "user" })).status,
175
+ ).toBe(403);
176
+ // Nor edit one: a shared profile changes what other people's queued tasks run.
177
+ expect(
178
+ (
179
+ await call(
180
+ "PATCH",
181
+ "/api/model-profiles/claude-regular",
182
+ { model: "haiku" },
183
+ { "x-test-auth": "user" },
184
+ )
185
+ ).status,
186
+ ).toBe(403);
187
+ // Reads stay open to agents.
188
+ expect(
189
+ (await call("GET", "/api/model-profiles", undefined, { "x-agent-id": CLAUDE_ID })).status,
190
+ ).toBe(200);
191
+ });
192
+
193
+ test("a taken name is 409, a bad name or an unsupported effort is 400", async () => {
194
+ const body = { name: "devin-any", harness: "devin", model: "devin" };
195
+ expect((await call("POST", "/api/model-profiles", body)).status).toBe(201);
196
+ expect((await call("POST", "/api/model-profiles", body)).status).toBe(409);
197
+ expect(
198
+ (await call("POST", "/api/model-profiles", { ...body, name: "Has Spaces" })).status,
199
+ ).toBe(400);
200
+ // Devin takes no reasoning effort, the same rule the adapter applies at spawn.
201
+ const effort = await call("POST", "/api/model-profiles", {
202
+ ...body,
203
+ name: "devin-high",
204
+ effort: "high",
205
+ });
206
+ expect(effort.status).toBe(400);
207
+ expect(((await effort.json()) as { error: string }).error).toContain("not supported");
208
+ });
209
+
210
+ test("a supported level with no transport effect (Opencode off) is accepted on POST and PATCH", async () => {
211
+ const body = { harness: "opencode", model: "openai/gpt-5.6-sol" };
212
+ const off = await call("POST", "/api/model-profiles", {
213
+ ...body,
214
+ name: "oc-off",
215
+ effort: "off",
216
+ });
217
+ expect(off.status).toBe(201);
218
+ expect(((await off.json()) as { effort: string }).effort).toBe("off");
219
+
220
+ expect(
221
+ (await call("POST", "/api/model-profiles", { ...body, name: "oc-low", effort: "low" }))
222
+ .status,
223
+ ).toBe(201);
224
+ const patched = await call("PATCH", "/api/model-profiles/oc-low", { effort: "off" });
225
+ expect(patched.status).toBe(200);
226
+ // A PATCH that leaves `off` in place (a description edit) re-validates it too.
227
+ expect(
228
+ (await call("PATCH", "/api/model-profiles/oc-off", { description: "kept" })).status,
229
+ ).toBe(200);
230
+
231
+ // Control: a level this model does not list is still refused, on both verbs.
232
+ const max = await call("POST", "/api/model-profiles", {
233
+ ...body,
234
+ name: "oc-max",
235
+ effort: "max",
236
+ });
237
+ expect(max.status).toBe(400);
238
+ expect((await call("PATCH", "/api/model-profiles/oc-low", { effort: "max" })).status).toBe(400);
239
+ });
240
+
241
+ test("a tier seed and a profile in use cannot be deleted", async () => {
242
+ await createModelProfile({
243
+ name: "claude-smart",
244
+ harness: "claude",
245
+ model: "opus",
246
+ seededFrom: "tier:smart",
247
+ });
248
+ expect((await call("DELETE", "/api/model-profiles/claude-smart", undefined)).status).toBe(409);
249
+
250
+ await createModelProfile({ name: "claude-mine", harness: "claude", model: "opus" });
251
+ await upsertSwarmConfig({
252
+ scope: "agent",
253
+ scopeId: CLAUDE_ID,
254
+ key: "MODEL_PROFILE",
255
+ value: "claude-mine",
256
+ exportToEnv: false,
257
+ });
258
+ const inUse = await call("DELETE", "/api/model-profiles/claude-mine", undefined);
259
+ expect(inUse.status).toBe(409);
260
+ expect(((await inUse.json()) as { error: string }).error).toContain("1 agent(s)");
261
+ });
262
+ });
263
+
264
+ describe("profile references and deletion are atomic", () => {
265
+ const taskBody = {
266
+ task: "racing work",
267
+ agentId: CLAUDE_ID,
268
+ routingReason: "continuity",
269
+ profile: "claude-racy",
270
+ };
271
+
272
+ async function taskCount(): Promise<number> {
273
+ return (
274
+ (await getDbClient().get<{ n: number }>("SELECT COUNT(*) AS n FROM agent_tasks"))?.n ?? 0
275
+ );
276
+ }
277
+
278
+ test("a delete that lands after create validated the profile makes the create a 400", async () => {
279
+ await createModelProfile({ name: "claude-racy", harness: "claude", model: "opus" });
280
+ const validate = profilesDb.taskProfileError;
281
+ let deleteStatus = 0;
282
+ const spy = spyOn(profilesDb, "taskProfileError").mockImplementation(async (check) => {
283
+ const error = await validate(check);
284
+ deleteStatus = (await call("DELETE", "/api/model-profiles/claude-racy", undefined)).status;
285
+ return error;
286
+ });
287
+ try {
288
+ const created = await call("POST", "/api/tasks", taskBody);
289
+ expect(deleteStatus).toBe(200);
290
+ expect(created.status).toBe(400);
291
+ expect(((await created.json()) as { error: string }).error).toContain("claude-racy");
292
+ expect(await taskCount()).toBe(0);
293
+ } finally {
294
+ spy.mockRestore();
295
+ }
296
+ });
297
+
298
+ test("a create that lands after delete counted references makes the delete a 409", async () => {
299
+ await createModelProfile({ name: "claude-racy", harness: "claude", model: "opus" });
300
+ const count = profilesDb.modelProfileReferences;
301
+ let createStatus = 0;
302
+ const spy = spyOn(profilesDb, "modelProfileReferences").mockImplementation(async (name) => {
303
+ const refs = await count(name);
304
+ createStatus = (await call("POST", "/api/tasks", taskBody)).status;
305
+ return refs;
306
+ });
307
+ try {
308
+ const deleted = await call("DELETE", "/api/model-profiles/claude-racy", undefined);
309
+ expect(createStatus).toBe(201);
310
+ expect(deleted.status).toBe(409);
311
+ expect(await profilesDb.getModelProfileByName("claude-racy")).not.toBeNull();
312
+ expect(await taskCount()).toBe(1);
313
+ } finally {
314
+ spy.mockRestore();
315
+ }
316
+ });
317
+
318
+ test("a delete that lands after PATCH /runtime validated the default makes the PATCH a 400", async () => {
319
+ await createModelProfile({ name: "claude-racy", harness: "claude", model: "opus" });
320
+ const lookup = profilesDb.getModelProfileByName;
321
+ let deleteStatus = 0;
322
+ const spy = spyOn(profilesDb, "getModelProfileByName").mockImplementation(async (name) => {
323
+ const profile = await lookup(name);
324
+ if (!deleteStatus) {
325
+ deleteStatus = -1;
326
+ spy.mockImplementation(lookup);
327
+ deleteStatus = (await call("DELETE", "/api/model-profiles/claude-racy", undefined)).status;
328
+ }
329
+ return profile;
330
+ });
331
+ try {
332
+ const set = await call("PATCH", `/api/agents/${CLAUDE_ID}/runtime`, {
333
+ harness_provider: "claude",
334
+ profile: "claude-racy",
335
+ });
336
+ expect(deleteStatus).toBe(200);
337
+ expect(set.status).toBe(400);
338
+ expect(await modelProfileConfig(CLAUDE_ID)).toBeNull();
339
+ } finally {
340
+ spy.mockRestore();
341
+ }
342
+ });
343
+
344
+ test("every write path is held to it: tasks, agent defaults and deletes", async () => {
345
+ await expect(
346
+ createTaskExtended("work", { agentId: CLAUDE_ID, profile: "nope" }),
347
+ ).rejects.toThrow("model-profile-missing");
348
+ await expect(
349
+ upsertSwarmConfig({
350
+ scope: "agent",
351
+ scopeId: CLAUDE_ID,
352
+ key: "MODEL_PROFILE",
353
+ value: "nope",
354
+ }),
355
+ ).rejects.toThrow("model-profile-missing");
356
+
357
+ await createModelProfile({ name: "claude-racy", harness: "claude", model: "opus" });
358
+ const task = await createTaskExtended("work", { agentId: CLAUDE_ID, profile: "claude-racy" });
359
+ expect(await profilesDb.deleteModelProfile("claude-racy")).toBe("in-use");
360
+ await expect(
361
+ getDbClient().run("UPDATE agent_tasks SET profile = 'nope' WHERE id = ?", [task.id]),
362
+ ).rejects.toThrow("model-profile-missing");
363
+
364
+ // Control: a finished task no longer holds the profile.
365
+ await getDbClient().run("UPDATE agent_tasks SET status = 'completed' WHERE id = ?", [task.id]);
366
+ expect(await profilesDb.deleteModelProfile("claude-racy")).toBe("deleted");
367
+ expect(await profilesDb.deleteModelProfile("claude-racy")).toBe("missing");
368
+ });
369
+ });
370
+
371
+ describe("a superseded task holds its profile until its resume exists", () => {
372
+ // Supersede and resume creation are separate writes; the resume copies the
373
+ // parent's profile, and after a restart the heartbeat's orphan repair makes it.
374
+ async function supersededWithProfile(finishedAgoMs = 0): Promise<string> {
375
+ await createModelProfile({ name: "claude-handoff", harness: "claude", model: "opus" });
376
+ const task = await createTaskExtended("handoff work", {
377
+ agentId: CLAUDE_ID,
378
+ profile: "claude-handoff",
379
+ });
380
+ await startTask(task.id);
381
+ expect(await supersedeTask(task.id, { reason: "crash_recovery", resumeTaskId: null })).not.toBe(
382
+ null,
383
+ );
384
+ if (finishedAgoMs) {
385
+ await getDbClient().run("UPDATE agent_tasks SET finishedAt = ? WHERE id = ?", [
386
+ new Date(Date.now() - finishedAgoMs).toISOString(),
387
+ task.id,
388
+ ]);
389
+ }
390
+ return task.id;
391
+ }
392
+
393
+ async function resumeChildren(parentId: string) {
394
+ return await getDbClient().query<{ id: string; profile: string | null }>(
395
+ "SELECT id, profile FROM agent_tasks WHERE parentTaskId = ? AND taskType = 'resume'",
396
+ [parentId],
397
+ );
398
+ }
399
+
400
+ test("a delete inside POST /supersede's handoff is a 409 and the resume keeps the profile", async () => {
401
+ await createModelProfile({ name: "claude-handoff", harness: "claude", model: "opus" });
402
+ const task = await createTaskExtended("handoff work", {
403
+ agentId: CLAUDE_ID,
404
+ profile: "claude-handoff",
405
+ });
406
+ await startTask(task.id);
407
+ const create = followUps.createResumeFollowUp;
408
+ let deleted: Response | null = null;
409
+ const spy = spyOn(followUps, "createResumeFollowUp").mockImplementation(async (args) => {
410
+ deleted = await call("DELETE", "/api/model-profiles/claude-handoff", undefined);
411
+ return await create(args);
412
+ });
413
+ try {
414
+ const superseded = await call(
415
+ "POST",
416
+ `/api/tasks/${task.id}/supersede`,
417
+ { reason: "manual_supersede" },
418
+ { "x-test-auth": "operator", "x-agent-id": CLAUDE_ID },
419
+ );
420
+ expect(deleted).not.toBeNull();
421
+ const refused = deleted as unknown as Response;
422
+ expect(refused.status).toBe(409);
423
+ expect(((await refused.json()) as { error: string }).error).toContain("awaiting-resume");
424
+ expect(superseded.status).toBe(200);
425
+ const body = (await superseded.json()) as { kind: string; resumeTaskId: string | null };
426
+ expect(body.kind).toBe("resumed");
427
+ expect(await resumeChildren(task.id)).toEqual([
428
+ { id: body.resumeTaskId!, profile: "claude-handoff" },
429
+ ]);
430
+ } finally {
431
+ spy.mockRestore();
432
+ }
433
+ });
434
+
435
+ test("after a restart the profile survives until orphan repair makes the resume", async () => {
436
+ const parentId = await supersededWithProfile(10 * 60 * 1000);
437
+ expect(await profilesDb.deleteModelProfile("claude-handoff")).toBe("in-use");
438
+ const refused = await call("DELETE", "/api/model-profiles/claude-handoff", undefined);
439
+ expect(refused.status).toBe(409);
440
+
441
+ await codeLevelTriage();
442
+ const children = await resumeChildren(parentId);
443
+ expect(children).toHaveLength(1);
444
+ expect(children[0]?.profile).toBe("claude-handoff");
445
+ // A second sweep finds nothing left to repair.
446
+ await codeLevelTriage();
447
+ expect(await resumeChildren(parentId)).toHaveLength(1);
448
+
449
+ // The resume now holds it; once it finishes the profile can go.
450
+ expect(await profilesDb.deleteModelProfile("claude-handoff")).toBe("in-use");
451
+ await getDbClient().run("UPDATE agent_tasks SET status = 'completed' WHERE id = ?", [
452
+ children[0]!.id,
453
+ ]);
454
+ expect(await profilesDb.deleteModelProfile("claude-handoff")).toBe("deleted");
455
+ });
456
+
457
+ test("control: a superseded task past the repair window, or with its resume, holds nothing", async () => {
458
+ await supersededWithProfile(25 * 60 * 60 * 1000);
459
+ expect(await profilesDb.modelProfileReferences("claude-handoff")).toEqual({
460
+ agentIds: [],
461
+ openTaskCount: 0,
462
+ });
463
+ expect(await profilesDb.deleteModelProfile("claude-handoff")).toBe("deleted");
464
+
465
+ const parentId = await supersededWithProfile();
466
+ const resume = await followUps.createResumeFollowUp({
467
+ parentId,
468
+ reason: "crash_recovery",
469
+ });
470
+ expect(resume.kind).toBe("created");
471
+ await getDbClient().run("UPDATE agent_tasks SET status = 'cancelled' WHERE parentTaskId = ?", [
472
+ parentId,
473
+ ]);
474
+ expect(await profilesDb.deleteModelProfile("claude-handoff")).toBe("deleted");
475
+ });
476
+ });
477
+
478
+ describe("agent default via PATCH /api/agents/{id}/runtime", () => {
479
+ test("sets MODEL_PROFILE with exportToEnv off, and null clears it", async () => {
480
+ await createModelProfile({
481
+ name: "claude-opus-high",
482
+ harness: "claude",
483
+ model: "opus",
484
+ effort: "high",
485
+ });
486
+ const set = await call("PATCH", `/api/agents/${CLAUDE_ID}/runtime`, {
487
+ harness_provider: "claude",
488
+ profile: "claude-opus-high",
489
+ });
490
+ expect(set.status).toBe(200);
491
+ expect(await modelProfileConfig(CLAUDE_ID)).toEqual({
492
+ value: "claude-opus-high",
493
+ exportToEnv: 0,
494
+ });
495
+
496
+ const cleared = await call("PATCH", `/api/agents/${CLAUDE_ID}/runtime`, {
497
+ harness_provider: "claude",
498
+ profile: null,
499
+ });
500
+ expect(cleared.status).toBe(200);
501
+ expect(await modelProfileConfig(CLAUDE_ID)).toBeNull();
502
+ });
503
+
504
+ test("an unknown profile, another harness's profile, or profile with model is 400", async () => {
505
+ await createModelProfile({ name: "codex-sol", harness: "codex", model: "gpt-5.6-sol" });
506
+ for (const body of [
507
+ { harness_provider: "claude", profile: "nope" },
508
+ { harness_provider: "claude", profile: "codex-sol" },
509
+ { harness_provider: "codex", profile: "codex-sol", model: "gpt-5.6-sol" },
510
+ ]) {
511
+ expect((await call("PATCH", `/api/agents/${CODEX_ID}/runtime`, body)).status).toBe(400);
512
+ }
513
+ expect(await modelProfileConfig(CODEX_ID)).toBeNull();
514
+ });
515
+
516
+ test("setting model clears the default, so a stale profile never beats a fresh override", async () => {
517
+ await createModelProfile({
518
+ name: "claude-opus-high",
519
+ harness: "claude",
520
+ model: "opus",
521
+ effort: "high",
522
+ });
523
+ await call("PATCH", `/api/agents/${CLAUDE_ID}/runtime`, {
524
+ harness_provider: "claude",
525
+ profile: "claude-opus-high",
526
+ });
527
+ expect(await modelProfileConfig(CLAUDE_ID)).not.toBeNull();
528
+ const res = await call("PATCH", `/api/agents/${CLAUDE_ID}/runtime`, {
529
+ harness_provider: "claude",
530
+ model: "sonnet",
531
+ allow_custom_model: true,
532
+ });
533
+ expect(res.status).toBe(200);
534
+ expect(await modelProfileConfig(CLAUDE_ID)).toBeNull();
535
+ });
536
+
537
+ test("changing MODEL_OVERRIDE or REASONING_EFFORT_OVERRIDE through config clears the default", async () => {
538
+ await createModelProfile({
539
+ name: "claude-opus-high",
540
+ harness: "claude",
541
+ model: "opus",
542
+ effort: "high",
543
+ });
544
+ for (const key of ["MODEL_OVERRIDE", "REASONING_EFFORT_OVERRIDE"]) {
545
+ await upsertSwarmConfig({
546
+ scope: "agent",
547
+ scopeId: CLAUDE_ID,
548
+ key: "MODEL_PROFILE",
549
+ value: "claude-opus-high",
550
+ exportToEnv: false,
551
+ });
552
+ await upsertSwarmConfig({
553
+ scope: "agent",
554
+ scopeId: CLAUDE_ID,
555
+ key,
556
+ value: key === "MODEL_OVERRIDE" ? "haiku" : "low",
557
+ });
558
+ expect(await modelProfileConfig(CLAUDE_ID)).toBeNull();
559
+ }
560
+ // Re-saving the same value is not a change: the default stays.
561
+ await upsertSwarmConfig({
562
+ scope: "agent",
563
+ scopeId: CLAUDE_ID,
564
+ key: "MODEL_PROFILE",
565
+ value: "claude-opus-high",
566
+ exportToEnv: false,
567
+ });
568
+ await upsertSwarmConfig({
569
+ scope: "agent",
570
+ scopeId: CLAUDE_ID,
571
+ key: "MODEL_OVERRIDE",
572
+ value: "haiku",
573
+ });
574
+ expect(await modelProfileConfig(CLAUDE_ID)).not.toBeNull();
575
+ });
576
+ });
577
+
578
+ describe("task creation with a profile", () => {
579
+ test("POST /api/tasks stores the profile; another harness's profile is 400", async () => {
580
+ await createModelProfile({ name: "claude-opus", harness: "claude", model: "opus" });
581
+ const ok = await call("POST", "/api/tasks", {
582
+ task: "profile work",
583
+ agentId: CLAUDE_ID,
584
+ routingReason: "continuity",
585
+ profile: "claude-opus",
586
+ });
587
+ expect(ok.status).toBe(201);
588
+ const { id } = (await ok.json()) as { id: string };
589
+ expect((await getTaskById(id))?.profile).toBe("claude-opus");
590
+
591
+ const wrong = await call("POST", "/api/tasks", {
592
+ task: "profile work",
593
+ agentId: CODEX_ID,
594
+ routingReason: "continuity",
595
+ profile: "claude-opus",
596
+ });
597
+ expect(wrong.status).toBe(400);
598
+ expect(((await wrong.json()) as { error: string }).error).toContain(
599
+ "runs on the claude harness",
600
+ );
601
+ });
602
+
603
+ test("taskProfileError: unknown, mixed with model or tier, and pooled (no agent) cases", async () => {
604
+ await createModelProfile({ name: "claude-opus", harness: "claude", model: "opus" });
605
+ expect(await taskProfileError({ profile: "nope" })).toContain("Unknown model profile");
606
+ expect(await taskProfileError({ profile: "claude-opus", model: "opus" })).toContain("not both");
607
+ expect(await taskProfileError({ profile: "claude-opus", modelTier: "smart" })).toContain(
608
+ "not both",
609
+ );
610
+ expect(await taskProfileError({ profile: "claude-opus" })).toBeNull();
611
+ expect(await taskProfileError({ profile: "claude-opus", agentId: CLAUDE_ID })).toBeNull();
612
+ expect(await taskProfileError({})).toBeNull();
613
+ });
614
+ });