@desplega.ai/agent-swarm 1.158.0 → 1.159.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 (272) hide show
  1. package/dist/{acp-adapter-xaq4n6pc.js → acp-adapter-qhch0n2v.js} +1 -1
  2. package/dist/{actions-p9hz5yat.js → actions-dtsh1mfb.js} +33 -29
  3. package/dist/{anthropic-messages-51pz26qe.js → anthropic-messages-f2h06k64.js} +8 -3
  4. package/dist/{app-g26wdcz6.js → app-y1b9srva.js} +10 -9
  5. package/dist/{assistant-wydfqkb3.js → assistant-31hwy5xj.js} +34 -30
  6. package/dist/{azure-openai-responses-8gksqtmz.js → azure-openai-responses-0k1mfax5.js} +12 -13
  7. package/dist/{boot-reembed-se8zmdcp.js → boot-reembed-48d2qpkt.js} +11 -11
  8. package/dist/{boot-reembed-g5ckg9yb.js → boot-reembed-w0bryhkr.js} +12 -12
  9. package/dist/{boot-scrub-logs-wfxqan61.js → boot-scrub-logs-t65wbrn7.js} +9 -8
  10. package/dist/{boot-seeds-s3q01xj3.js → boot-seeds-h7c32hvm.js} +12 -11
  11. package/dist/{claude-adapter-tv2x4f36.js → claude-adapter-1t6611ne.js} +26 -21
  12. package/dist/{claude-managed-adapter-41b2s7fd.js → claude-managed-adapter-werp659j.js} +4 -2
  13. package/dist/{claude-managed-setup-60g8aazx.js → claude-managed-setup-y6rdgbc1.js} +11 -2
  14. package/dist/claude-sdk-session-zhkyk8yw.js +29319 -0
  15. package/dist/{cli-vj6h9kc2.js → cli-0m7wct0d.js} +59 -1
  16. package/dist/{cli-wscdvcqz.js → cli-0n2e4x49.js} +1 -1
  17. package/dist/cli-0xqde53b.js +176 -0
  18. package/dist/{cli-xberxa1r.js → cli-10ym38qc.js} +2 -2
  19. package/dist/{cli-5b6b62tz.js → cli-13hw4z91.js} +1 -1
  20. package/dist/{cli-rqz8qbw7.js → cli-20035trh.js} +50 -18
  21. package/dist/{cli-k0g1sjzm.js → cli-31nkw3g3.js} +3 -3
  22. package/dist/cli-3gvrr8vj.js +1422 -0
  23. package/dist/{cli-c07r9a2m.js → cli-48527fpw.js} +9 -8
  24. package/dist/{cli-24k806sc.js → cli-4aj25szq.js} +2 -2
  25. package/dist/{cli-7gtvvpbh.js → cli-4tvgz99m.js} +7 -7
  26. package/dist/cli-503ya656.js +455 -0
  27. package/dist/{cli-cw9zck8j.js → cli-5f2hq128.js} +8 -4
  28. package/dist/{cli-s8eet254.js → cli-68bmme5p.js} +11078 -27833
  29. package/dist/{cli-ftfvggzx.js → cli-79aq9cgb.js} +794 -563
  30. package/dist/cli-7cxw8b60.js +14910 -0
  31. package/dist/cli-7kyk9jr3.js +22 -0
  32. package/dist/cli-7zttzetj.js +324 -0
  33. package/dist/{cli-y95wp3tg.js → cli-96qd5psa.js} +1 -1
  34. package/dist/{cli-g8djp6qe.js → cli-9yg65c1j.js} +22 -0
  35. package/dist/{cli-3gmcwfyx.js → cli-ardpzhjm.js} +5 -5
  36. package/dist/{cli-7s3syk22.js → cli-b6thtzac.js} +5 -5
  37. package/dist/{cli-00w2c66p.js → cli-bgv70kym.js} +13 -2
  38. package/dist/{cli-j55n64b6.js → cli-bv0a38v9.js} +163 -44
  39. package/dist/cli-ca2w7zv7.js +115 -0
  40. package/dist/cli-cpvsaqsw.js +333 -0
  41. package/dist/cli-cygyqj3b.js +754 -0
  42. package/dist/{cli-x24pshtq.js → cli-dvjpkkjd.js} +6 -6
  43. package/dist/{cli-5d10vd2m.js → cli-e0ndxhtk.js} +4 -4
  44. package/dist/{cli-4198qc9v.js → cli-e4xnc0qp.js} +15 -3
  45. package/dist/{cli-rjehvrts.js → cli-eg7cdg68.js} +8 -8
  46. package/dist/cli-ejpygnqz.js +3815 -0
  47. package/dist/{cli-fgqxwrpv.js → cli-fjtp3463.js} +1631 -127
  48. package/dist/{cli-zt1227k1.js → cli-fkqhbg8g.js} +5 -3
  49. package/dist/{cli-47m6dkd3.js → cli-g9df2m37.js} +409 -168
  50. package/dist/cli-gzyh0j0g.js +8455 -0
  51. package/dist/cli-hgqfttfc.js +183 -0
  52. package/dist/cli-hj65wsts.js +17755 -0
  53. package/dist/cli-j9z8mfx3.js +476 -0
  54. package/dist/{cli-aehm1rbq.js → cli-kc3dp84d.js} +1135 -61
  55. package/dist/{cli-h38x0qhg.js → cli-kdnehe0t.js} +4 -3
  56. package/dist/{cli-20cj37vt.js → cli-m4excttz.js} +136 -2216
  57. package/dist/{cli-8qmme6vd.js → cli-mg24hkt4.js} +2 -2
  58. package/dist/{cli-xhaeg2nc.js → cli-mzkqpxee.js} +1 -1
  59. package/dist/{cli-q67y4wzm.js → cli-phsaj4xq.js} +24 -2
  60. package/dist/{cli-pw9rj5t9.js → cli-pqetb83f.js} +1 -176
  61. package/dist/{cli-rj19hc34.js → cli-qjxy52w7.js} +1 -1
  62. package/dist/{cli-53sz8gkz.js → cli-qv4jv8bx.js} +1 -1
  63. package/dist/cli-qvnnx4ct.js +1051 -0
  64. package/dist/cli-rb9m6rpe.js +4980 -0
  65. package/dist/{cli-tt9k8r35.js → cli-rfezr056.js} +476 -909
  66. package/dist/{cli-ggm92wcn.js → cli-rgjprct3.js} +1 -0
  67. package/dist/{cli-9httdpyq.js → cli-rrqmqr1s.js} +1 -1
  68. package/dist/cli-rykfmgwg.js +102 -0
  69. package/dist/{cli-eetrvv91.js → cli-t3p77qk1.js} +1 -1
  70. package/dist/{cli-68w09sjw.js → cli-vn0qv9cj.js} +25 -88
  71. package/dist/{cli-987hg0aa.js → cli-vwbbaecp.js} +2 -2
  72. package/dist/cli-wd9hz40d.js +69 -0
  73. package/dist/{cli-j57qkrpq.js → cli-wgjx0q33.js} +13 -13
  74. package/dist/cli-xgpf00sk.js +2149 -0
  75. package/dist/{cli-mcnhgnn1.js → cli-ykk5jaax.js} +184 -26
  76. package/dist/{cli-aky8tctm.js → cli-z9x0sh1t.js} +3 -3
  77. package/dist/{cli-1hkrch3k.js → cli-zw65mab6.js} +5 -5
  78. package/dist/cli.js +38 -29
  79. package/dist/cloudflare-workers-ai-system-one-1sg586qv.js +46 -0
  80. package/dist/{codex-adapter-cnm8wk11.js → codex-adapter-jqfqqa21.js} +21 -14
  81. package/dist/{codex-hook-9hptmdhj.js → codex-hook-5eys3dya.js} +2 -2
  82. package/dist/{codex-session-runner-gfg547zj.js → codex-session-runner-zd341dsm.js} +21 -14
  83. package/dist/{commands-e3bn0p75.js → commands-vfsysz4s.js} +9 -8
  84. package/dist/{db-03v9kzr2.js → db-s3znhvbn.js} +15 -10
  85. package/dist/{dispatcher-nrmg8yht.js → dispatcher-5xn0c7rf.js} +32 -28
  86. package/dist/{dsh-adapter-k03nfqa6.js → dsh-adapter-894mgfvs.js} +3 -3
  87. package/dist/{e2b-93x1k0j5.js → e2b-ez07znam.js} +4 -3
  88. package/dist/execute-71ysgv35.js +965 -0
  89. package/dist/{google-generative-ai-bxmv50nc.js → google-generative-ai-1nfcdphp.js} +5 -4
  90. package/dist/{google-vertex-809af514.js → google-vertex-1tqcd7b1.js} +5 -4
  91. package/dist/{handlers-aqcedbyb.js → handlers-5w4jwycr.js} +35 -31
  92. package/dist/{hook-7vkskkre.js → hook-853bxrr6.js} +16 -13
  93. package/dist/{hook-ybvmrmhq.js → hook-z0hbxj85.js} +16 -13
  94. package/dist/{http-2eqa8t24.js → http-akc3wy3m.js} +600 -123
  95. package/dist/{index-f6cryxwe.js → index-4a5njnm1.js} +2 -59
  96. package/dist/{index-a3vn96n8.js → index-5sz16ksh.js} +187 -47
  97. package/dist/{index-c6zsv1bd.js → index-bjmjh9h4.js} +11 -11
  98. package/dist/{index-chgdkq36.js → index-cd151qkq.js} +32 -28
  99. package/dist/{index-x71bvq0p.js → index-k661dzzr.js} +33 -29
  100. package/dist/{index-1yks3qcr.js → index-ttpbhnjg.js} +32 -28
  101. package/dist/{keepalive-vtkf5n99.js → keepalive-8p9rpvz5.js} +11 -10
  102. package/dist/{lead-h3sp357x.js → lead-jp69z5h3.js} +49 -45
  103. package/dist/llama-cpp-classify-t2tccams.js +335 -0
  104. package/dist/{maintenance-jzeqtk3q.js → maintenance-fwx4ev0k.js} +12 -12
  105. package/dist/{mistral-conversations-zx0kn554.js → mistral-conversations-bhvwqcnk.js} +14 -17
  106. package/dist/{oauth-refresh-sweep-qp3dzmdp.js → oauth-refresh-sweep-cwcq1ctx.js} +10 -9
  107. package/dist/{onboard-tmsp0qbz.js → onboard-2b4q0t84.js} +6 -5
  108. package/dist/{openai-codex-responses-4vjqzvjp.js → openai-codex-responses-nekthgks.js} +21 -8
  109. package/dist/{openai-completions-9hjrqbw8.js → openai-completions-hxtzwj0s.js} +8 -11
  110. package/dist/{openai-responses-7b1pn0xt.js → openai-responses-w4r118w7.js} +22 -17
  111. package/dist/{opencode-adapter-x6x2da1z.js → opencode-adapter-zz7xvjj6.js} +6 -4
  112. package/dist/{openrouter-images-9f9ek4rw.js → openrouter-images-b6sqqjgn.js} +4 -6
  113. package/dist/{otel-impl-9mz73q65.js → otel-impl-g8rs4vwr.js} +1 -1
  114. package/dist/{pi-messages-txpjj39q.js → pi-messages-vxdt8v7j.js} +2 -1
  115. package/dist/{pi-mono-adapter-m6dvv0dz.js → pi-mono-adapter-n0me28wt.js} +29 -17
  116. package/dist/pricing-refresh-0v77385v.js +39 -0
  117. package/dist/{rbac-roles-c9wqz83x.js → rbac-roles-krjyazbj.js} +10 -9
  118. package/dist/{render-v2-gryrh9h8.js → render-v2-3wjsp9aw.js} +10 -9
  119. package/dist/runtime-ktjysacx.js +24 -0
  120. package/dist/{setup-q9720bt6.js → setup-yeqryke6.js} +6 -5
  121. package/dist/typesafe-system-one-96960jxp.js +27 -0
  122. package/dist/{virtual-modules-3htpgn8x.js → virtual-modules-dzdeq1sf.js} +21 -13
  123. package/dist/{work-objects-b76x7deb.js → work-objects-3pakpkw0.js} +35 -31
  124. package/dist/{worker-6vrxbv41.js → worker-85testgj.js} +49 -45
  125. package/openapi.json +863 -14
  126. package/package.json +9 -8
  127. package/src/be/db/agents.ts +20 -0
  128. package/src/be/db/tasks/human-free.ts +442 -0
  129. package/src/be/db/tasks/read.ts +56 -1
  130. package/src/be/db/tasks/write.ts +107 -11
  131. package/src/be/db.ts +89 -165
  132. package/src/be/harness-model-support.ts +182 -0
  133. package/src/be/human-free-drain.ts +67 -0
  134. package/src/be/migrations/182_task_human_free_flag.sql +63 -0
  135. package/src/be/migrations/183_human_free_reclassify_queue.sql +25 -0
  136. package/src/be/migrations/184_model_catalog.sql +120 -0
  137. package/src/be/model-catalog-store.ts +644 -0
  138. package/src/be/model-tier-keys.ts +31 -0
  139. package/src/be/model-tier-resolution.ts +489 -0
  140. package/src/be/model-validation.ts +112 -0
  141. package/src/be/models-catalog.ts +61 -7
  142. package/src/be/modelsdev-cache.json +82 -0
  143. package/src/be/modelsdev-cache.ts +2 -0
  144. package/src/be/pricing-refresh.ts +154 -12
  145. package/src/be/scripts/typecheck.ts +15 -0
  146. package/src/be/seed-scripts/catalog/task-failure-audit.ts +61 -9
  147. package/src/be/swarm-config-guard.ts +6 -0
  148. package/src/cli.tsx +10 -0
  149. package/src/commands/claude-managed-setup.ts +3 -1
  150. package/src/commands/runner.ts +76 -2
  151. package/src/extensions/contract-types.generated.ts +1 -1
  152. package/src/heartbeat/heartbeat.ts +64 -15
  153. package/src/heartbeat/templates.ts +2 -2
  154. package/src/http/agents.ts +49 -1
  155. package/src/http/index.ts +8 -0
  156. package/src/http/models-catalog.ts +357 -8
  157. package/src/http/poll.ts +45 -2
  158. package/src/http/schedules.ts +36 -6
  159. package/src/http/session-data.ts +2 -5
  160. package/src/http/stats.ts +6 -8
  161. package/src/http/tasks.ts +26 -1
  162. package/src/http/usage-cache.ts +56 -15
  163. package/src/http/workflows.ts +35 -8
  164. package/src/prompts/session-templates.ts +1 -0
  165. package/src/providers/claude-managed-models.ts +47 -161
  166. package/src/providers/codex-adapter.ts +4 -4
  167. package/src/providers/codex-models.ts +65 -198
  168. package/src/providers/reasoning-effort.ts +44 -140
  169. package/src/rbac/legacy-policy.ts +25 -0
  170. package/src/rbac/permissions.ts +10 -0
  171. package/src/scripts-runtime/sdk-allowlist.ts +4 -0
  172. package/src/scripts-runtime/types/stdlib.d.ts +15 -0
  173. package/src/scripts-runtime/types/swarm-extension.d.ts +3 -0
  174. package/src/scripts-runtime/types/swarm-sdk.d.ts +15 -0
  175. package/src/server.ts +6 -0
  176. package/src/slack/message-text.ts +56 -2
  177. package/src/slack/thread-buffer.ts +10 -2
  178. package/src/tests/agents-list-model-display.test.ts +4 -6
  179. package/src/tests/base-prompt.test.ts +3 -0
  180. package/src/tests/claude-adapter.test.ts +10 -0
  181. package/src/tests/claude-managed-adapter.test.ts +12 -18
  182. package/src/tests/codex-adapter.test.ts +2 -1
  183. package/src/tests/codex-gpt6-release.test.ts +66 -5
  184. package/src/tests/context-window.test.ts +7 -5
  185. package/src/tests/db-extraction-contract.test.ts +1 -0
  186. package/src/tests/db-task-read-contract.test.ts +5 -1
  187. package/src/tests/db-task-write-contract.test.ts +4 -1
  188. package/src/tests/docs-index-footprint.test.js +3 -3
  189. package/src/tests/explicit-model-validation.test.ts +332 -0
  190. package/src/tests/harness-model-support.test.ts +374 -0
  191. package/src/tests/heartbeat-checklist-config.test.ts +71 -0
  192. package/src/tests/heartbeat-checklist.test.ts +4 -2
  193. package/src/tests/heartbeat-orphan-resume.test.ts +48 -0
  194. package/src/tests/human-free-flag.test.ts +279 -0
  195. package/src/tests/human-free-oracle.ts +57 -0
  196. package/src/tests/human-free-reclassify.test.ts +669 -0
  197. package/src/tests/migration-046-budgets.test.ts +3 -3
  198. package/src/tests/model-catalog-migration.test.ts +134 -0
  199. package/src/tests/model-catalog-table.test.ts +249 -0
  200. package/src/tests/model-catalog-write-access.test.ts +387 -0
  201. package/src/tests/model-tier-resolution.test.ts +519 -0
  202. package/src/tests/models-catalog.test.ts +1 -1
  203. package/src/tests/multi-runtime-enabled-stats.test.ts +65 -1
  204. package/src/tests/package-publish.test.ts +137 -36
  205. package/src/tests/pricing-routes.test.ts +2 -2
  206. package/src/tests/rbac-engine.test.ts +42 -2
  207. package/src/tests/reasoning-effort.test.ts +109 -0
  208. package/src/tests/refresh-modelsdev-carryforward.test.ts +5 -5
  209. package/src/tests/runtime-model-catalog.test.ts +86 -0
  210. package/src/tests/script-connections.test.ts +5 -0
  211. package/src/tests/seed-scripts.test.ts +89 -0
  212. package/src/tests/session-costs.test.ts +14 -11
  213. package/src/tests/slack-message-text.test.ts +101 -0
  214. package/src/tests/slack-thread-buffer-context.test.ts +93 -0
  215. package/src/tests/task-cascade-fail.test.ts +148 -2
  216. package/src/tests/tool-annotations.test.ts +1 -1
  217. package/src/tests/usage-cache.test.ts +166 -0
  218. package/src/tests/workflow-executors.test.ts +5 -2
  219. package/src/tests/workflow-system-one-decision-review.test.ts +1021 -0
  220. package/src/tests/workflow-system-one-decision-save-warnings.test.ts +365 -0
  221. package/src/tests/workflow-system-one-decision.test.ts +2063 -0
  222. package/src/tools/model-catalog/index.ts +137 -0
  223. package/src/tools/schedules/create-schedule.ts +16 -1
  224. package/src/tools/schedules/patch-schedule.ts +17 -0
  225. package/src/tools/schedules/update-schedule.ts +17 -0
  226. package/src/tools/send-task.ts +14 -0
  227. package/src/tools/task-action.ts +12 -0
  228. package/src/tools/tool-config.ts +3 -1
  229. package/src/tools/workflows/create-workflow.ts +9 -2
  230. package/src/tools/workflows/patch-workflow-node.ts +17 -7
  231. package/src/tools/workflows/patch-workflow.ts +9 -2
  232. package/src/tools/workflows/update-workflow.ts +9 -2
  233. package/src/types.ts +30 -1
  234. package/src/utils/context-window.ts +47 -53
  235. package/src/utils/harness-cli-version.ts +115 -0
  236. package/src/utils/harness-model-error.ts +33 -0
  237. package/src/utils/runtime-model-catalog.ts +137 -0
  238. package/src/workflows/approval-resolution.ts +39 -0
  239. package/src/workflows/definition.ts +7 -0
  240. package/src/workflows/engine.ts +69 -1
  241. package/src/workflows/executors/agent-task.ts +9 -0
  242. package/src/workflows/executors/base.ts +53 -0
  243. package/src/workflows/executors/human-in-the-loop.ts +8 -8
  244. package/src/workflows/executors/index.ts +1 -0
  245. package/src/workflows/executors/registry.ts +2 -0
  246. package/src/workflows/executors/system-one-decision.ts +1232 -0
  247. package/src/workflows/executors/system-one-providers.ts +301 -0
  248. package/src/workflows/executors/workflow-llm.ts +13 -1
  249. package/src/workflows/readiness.ts +86 -0
  250. package/src/workflows/recovery.ts +12 -12
  251. package/src/workflows/resume.ts +23 -9
  252. package/templates/schedules/daily-blocker-digest/content.md +4 -5
  253. package/templates/skills/agent-browser/SKILL.md +6 -5
  254. package/templates/skills/agent-browser/content.md +6 -5
  255. package/templates/skills/heartbeat-runbook/config.json +1 -1
  256. package/templates/skills/heartbeat-runbook/content.md +2 -2
  257. package/templates/skills/swarm-scripts/SKILL.md +1 -1
  258. package/templates/skills/swarm-scripts/content.md +1 -1
  259. package/templates/skills/workflow-iterate/content.md +81 -1
  260. package/templates/workflows/claude-code-changelog-watch/content.md +2 -0
  261. package/templates/workflows/pr-review-status-sweep/content.md +2 -0
  262. package/dist/claude-sdk-session-t442qz6f.js +0 -39108
  263. package/dist/cli-0xjkj1j7.js +0 -61
  264. package/dist/cli-65hjmev5.js +0 -1141
  265. package/dist/cli-aw1jwp1r.js +0 -14075
  266. package/dist/cli-h967nb6r.js +0 -6603
  267. package/dist/cli-vapyk8sw.js +0 -20
  268. package/dist/cli-ws6rqycm.js +0 -247
  269. package/dist/pricing-refresh-v1cbr9te.js +0 -27
  270. package/src/providers/modelsdev-reasoning.json +0 -6453
  271. package/dist/{codex-login-x3jk4etn.js → codex-login-he9vrn90.js} +3 -3
  272. package/dist/{index-f24115ge.js → index-2wsdsnmm.js} +3 -3
@@ -0,0 +1,519 @@
1
+ // Model-catalog phase 3: claim-time model resolution.
2
+ //
3
+ // Drives the real `/api/poll` handler with mocked req/res and asserts what the
4
+ // server records on the task (`resolvedModel`, `modelSource`, `modelAlias`),
5
+ // plus the `latest:` guardrails and the MODEL_TIER_<PROVIDER>_<TIER> validator.
6
+
7
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { unlink } from "node:fs/promises";
9
+ import { closeDb, createAgent, createTaskExtended, getDbClient, initDb } from "../be/db";
10
+ import { type AgentTaskRow, rowToAgentTaskSummary } from "../be/db/tasks/read";
11
+ import {
12
+ deleteModelCatalogOverlay,
13
+ type ModelCatalogEntry,
14
+ reloadModelsCatalog,
15
+ replaceModelCatalog,
16
+ upsertModelCatalogOverlay,
17
+ } from "../be/model-catalog-store";
18
+ import {
19
+ getAgentModelTierOverrides,
20
+ invalidateTierResolutionCatalog,
21
+ isTierConfigKey,
22
+ parseModelTierOverridesHeader,
23
+ previewModelTiers,
24
+ resolveTaskModel,
25
+ tierConfigKey,
26
+ } from "../be/model-tier-resolution";
27
+ import { validateConfigValue } from "../be/swarm-config-guard";
28
+ import { handleModelsCatalog } from "../http/models-catalog";
29
+ import { handlePoll } from "../http/poll";
30
+ import { parseWorkerModelTierOverrides } from "../types";
31
+
32
+ const TEST_DB_PATH = "./test-model-tier-resolution.sqlite";
33
+ const DAY = 24 * 60 * 60 * 1000;
34
+ const ENV_KEYS = [
35
+ "MODEL_TIER_CLAUDE_SMART",
36
+ "MODEL_TIER_CODEX_SMART",
37
+ "MODEL_LATEST_SOAK_DAYS",
38
+ "MODEL_AUTO_UPGRADE",
39
+ ];
40
+
41
+ async function removeDbFiles(path: string): Promise<void> {
42
+ for (const suffix of ["", "-wal", "-shm"]) {
43
+ try {
44
+ await unlink(path + suffix);
45
+ } catch (error) {
46
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
47
+ }
48
+ }
49
+ }
50
+
51
+ function daysAgo(days: number): string {
52
+ return new Date(Date.now() - days * DAY).toISOString().slice(0, 10);
53
+ }
54
+
55
+ function entry(
56
+ provider: string,
57
+ modelId: string,
58
+ releaseDate: string,
59
+ priced = true,
60
+ ): ModelCatalogEntry {
61
+ return {
62
+ provider,
63
+ modelId,
64
+ releaseDate,
65
+ pricing: priced ? { input: 5, output: 25 } : null,
66
+ checkedAt: Date.now(),
67
+ };
68
+ }
69
+
70
+ async function seedCatalog(entries: ModelCatalogEntry[]): Promise<void> {
71
+ await replaceModelCatalog(entries);
72
+ invalidateTierResolutionCatalog();
73
+ }
74
+
75
+ type Trigger = { type: string; taskId: string; task: Record<string, unknown> };
76
+
77
+ async function callPoll(agentId: string, overridesHeader?: string): Promise<Trigger | null> {
78
+ let bodyStr = "";
79
+ const headers: Record<string, string> = { "x-agent-id": agentId };
80
+ if (overridesHeader !== undefined) headers["x-model-tier-overrides"] = overridesHeader;
81
+ const req = { method: "GET", url: "/api/poll", headers } as unknown as Parameters<
82
+ typeof handlePoll
83
+ >[0];
84
+ const res = {
85
+ setHeader() {},
86
+ writeHead() {},
87
+ end(body?: string) {
88
+ bodyStr = body ?? "";
89
+ },
90
+ } as unknown as Parameters<typeof handlePoll>[1];
91
+ await handlePoll(req, res, ["api", "poll"], new URLSearchParams(), agentId);
92
+ return (JSON.parse(bodyStr) as { trigger: Trigger | null }).trigger;
93
+ }
94
+
95
+ async function taskRow(id: string) {
96
+ return await getDbClient().get<{
97
+ resolvedModel: string | null;
98
+ modelSource: string | null;
99
+ modelAlias: string | null;
100
+ }>("SELECT resolvedModel, modelSource, modelAlias FROM agent_tasks WHERE id = ?", [id]);
101
+ }
102
+
103
+ function header(overrides: unknown): string {
104
+ return encodeURIComponent(JSON.stringify(overrides));
105
+ }
106
+
107
+ beforeAll(async () => {
108
+ await removeDbFiles(TEST_DB_PATH);
109
+ initDb(TEST_DB_PATH);
110
+ });
111
+
112
+ afterAll(async () => {
113
+ closeDb();
114
+ await removeDbFiles(TEST_DB_PATH);
115
+ });
116
+
117
+ beforeEach(async () => {
118
+ const client = getDbClient();
119
+ await client.run("DELETE FROM agent_tasks");
120
+ await client.run("DELETE FROM agents");
121
+ await client.run("DELETE FROM model_alias_resolutions");
122
+ await seedCatalog([
123
+ entry("anthropic", "claude-opus-5", daysAgo(200)),
124
+ entry("anthropic", "claude-opus-5-5", daysAgo(30)),
125
+ entry("anthropic", "claude-sonnet-5-5", daysAgo(10)),
126
+ ]);
127
+ });
128
+
129
+ afterEach(() => {
130
+ for (const key of ENV_KEYS) delete process.env[key];
131
+ });
132
+
133
+ describe("claim-time resolution via /api/poll", () => {
134
+ test("worker MODEL_TIER_SMART=sonnet → resolvedModel sonnet, modelSource worker-env", async () => {
135
+ const worker = await createAgent({
136
+ name: "w-env",
137
+ isLead: false,
138
+ status: "idle",
139
+ maxTasks: 1,
140
+ harnessProvider: "claude",
141
+ });
142
+ // What the runner sends: parsed from its own process env.
143
+ const overrides = parseWorkerModelTierOverrides({ MODEL_TIER_SMART: "sonnet" }, "claude");
144
+ expect(overrides).toEqual({ claude: { smart: "sonnet" } });
145
+ // Tier config is set too; the worker env must still win.
146
+ process.env.MODEL_TIER_CLAUDE_SMART = "latest:anthropic/opus@stable";
147
+
148
+ const task = await createTaskExtended("smart work", { agentId: worker.id, modelTier: "smart" });
149
+ const trigger = await callPoll(worker.id, header(overrides));
150
+
151
+ expect(trigger?.type).toBe("task_assigned");
152
+ expect(trigger?.task.resolvedModel).toBe("sonnet");
153
+ expect(trigger?.task.modelSource).toBe("worker-env");
154
+ expect(await taskRow(task.id)).toEqual({
155
+ resolvedModel: "sonnet",
156
+ modelSource: "worker-env",
157
+ modelAlias: null,
158
+ });
159
+ expect(await getAgentModelTierOverrides(worker.id)).toEqual({ claude: { smart: "sonnet" } });
160
+ });
161
+
162
+ test("worker latest: alias resolves to the concrete Sonnet id", async () => {
163
+ const worker = await createAgent({
164
+ name: "w-env-alias",
165
+ isLead: false,
166
+ status: "idle",
167
+ maxTasks: 1,
168
+ harnessProvider: "claude",
169
+ });
170
+ const task = await createTaskExtended("smart work", { agentId: worker.id, modelTier: "smart" });
171
+ await callPoll(worker.id, header({ claude: { smart: "latest:anthropic/sonnet" } }));
172
+ expect(await taskRow(task.id)).toEqual({
173
+ resolvedModel: "claude-sonnet-5-5",
174
+ modelSource: "worker-env",
175
+ modelAlias: "latest:anthropic/sonnet",
176
+ });
177
+ });
178
+
179
+ test("tier-config latest: alias → catalog id, recorded in model_alias_resolutions", async () => {
180
+ process.env.MODEL_TIER_CLAUDE_SMART = "latest:anthropic/opus@stable";
181
+ const worker = await createAgent({
182
+ name: "w-config",
183
+ isLead: false,
184
+ status: "idle",
185
+ maxTasks: 1,
186
+ harnessProvider: "claude",
187
+ });
188
+ const task = await createTaskExtended("smart work", { agentId: worker.id, modelTier: "smart" });
189
+ const trigger = await callPoll(worker.id, header({}));
190
+
191
+ expect(trigger?.task.resolvedModel).toBe("claude-opus-5-5");
192
+ expect(await taskRow(task.id)).toEqual({
193
+ resolvedModel: "claude-opus-5-5",
194
+ modelSource: "tier-config",
195
+ modelAlias: "latest:anthropic/opus@stable",
196
+ });
197
+ const changes = await getDbClient().query<{ alias: string; newModel: string }>(
198
+ "SELECT alias, newModel FROM model_alias_resolutions",
199
+ );
200
+ expect(changes).toEqual([
201
+ { alias: "latest:anthropic/opus@stable", newModel: "claude-opus-5-5" },
202
+ ]);
203
+ });
204
+
205
+ test("no overrides → tier-default; explicit task model wins over everything", async () => {
206
+ const worker = await createAgent({
207
+ name: "w-default",
208
+ isLead: false,
209
+ status: "idle",
210
+ maxTasks: 2,
211
+ harnessProvider: "claude",
212
+ });
213
+ const tierTask = await createTaskExtended("tier", { agentId: worker.id, modelTier: "smart" });
214
+ await callPoll(worker.id, header({ claude: {} }));
215
+ expect(await taskRow(tierTask.id)).toEqual({
216
+ resolvedModel: "opus",
217
+ modelSource: "tier-default",
218
+ modelAlias: null,
219
+ });
220
+
221
+ const modelTask = await createTaskExtended("model", {
222
+ agentId: worker.id,
223
+ model: "claude-opus-5",
224
+ modelTier: "smart",
225
+ });
226
+ await callPoll(worker.id, header({ claude: { smart: "sonnet" } }));
227
+ expect(await taskRow(modelTask.id)).toEqual({
228
+ resolvedModel: "claude-opus-5",
229
+ modelSource: "model",
230
+ modelAlias: null,
231
+ });
232
+ });
233
+
234
+ test("pool claim of an unassigned task records the resolution", async () => {
235
+ const worker = await createAgent({
236
+ name: "w-pool",
237
+ isLead: false,
238
+ status: "idle",
239
+ maxTasks: 1,
240
+ harnessProvider: "codex",
241
+ });
242
+ const task = await createTaskExtended("pool work", { modelTier: "regular" });
243
+ const trigger = await callPoll(worker.id, header({}));
244
+ expect(trigger?.taskId).toBe(task.id);
245
+ expect(await taskRow(task.id)).toEqual({
246
+ resolvedModel: "gpt-5.6-terra",
247
+ modelSource: "tier-default",
248
+ modelAlias: null,
249
+ });
250
+ });
251
+
252
+ test("task without model or tier records nothing", async () => {
253
+ const worker = await createAgent({
254
+ name: "w-none",
255
+ isLead: false,
256
+ status: "idle",
257
+ maxTasks: 1,
258
+ });
259
+ const task = await createTaskExtended("plain", { agentId: worker.id });
260
+ const trigger = await callPoll(worker.id);
261
+ expect(trigger?.task.resolvedModel).toBeUndefined();
262
+ expect(await taskRow(task.id)).toEqual({
263
+ resolvedModel: null,
264
+ modelSource: null,
265
+ modelAlias: null,
266
+ });
267
+ });
268
+ });
269
+
270
+ describe("task list summaries", () => {
271
+ test("carry the claim-time resolution the dashboard table shows", async () => {
272
+ const worker = await createAgent({
273
+ name: "w-summary",
274
+ isLead: false,
275
+ status: "idle",
276
+ maxTasks: 1,
277
+ harnessProvider: "claude",
278
+ });
279
+ process.env.MODEL_TIER_CLAUDE_SMART = "latest:anthropic/opus";
280
+ const task = await createTaskExtended("smart work", { agentId: worker.id, modelTier: "smart" });
281
+ await callPoll(worker.id);
282
+
283
+ const row = await getDbClient().get<AgentTaskRow>("SELECT * FROM agent_tasks WHERE id = ?", [
284
+ task.id,
285
+ ]);
286
+ expect(row).not.toBeNull();
287
+ const summary = rowToAgentTaskSummary(row as AgentTaskRow);
288
+ expect(summary.modelTier).toBe("smart");
289
+ expect(summary.resolvedModel).toBe("claude-opus-5-5");
290
+ expect(summary.modelSource).toBe("tier-config");
291
+ expect(summary.modelAlias).toBe("latest:anthropic/opus");
292
+ });
293
+ });
294
+
295
+ describe("latest: guardrails", () => {
296
+ const base = { modelTier: "smart", harnessProvider: "claude" as const, env: {} };
297
+
298
+ test("@stable skips models inside the soak window; @any does not", async () => {
299
+ await seedCatalog([
300
+ entry("anthropic", "claude-opus-5-5", daysAgo(30)),
301
+ entry("anthropic", "claude-opus-6", daysAgo(1)),
302
+ ]);
303
+ const stable = await resolveTaskModel({ ...base, model: "latest:anthropic/opus@stable" });
304
+ expect(stable?.resolvedModel).toBe("claude-opus-5-5");
305
+ const any = await resolveTaskModel({ ...base, model: "latest:anthropic/opus@any" });
306
+ expect(any?.resolvedModel).toBe("claude-opus-6");
307
+ process.env.MODEL_LATEST_SOAK_DAYS = "0";
308
+ const noSoak = await resolveTaskModel({ ...base, model: "latest:anthropic/opus" });
309
+ expect(noSoak?.resolvedModel).toBe("claude-opus-6");
310
+ });
311
+
312
+ test("unpriced and preview models are skipped", async () => {
313
+ await seedCatalog([
314
+ entry("anthropic", "claude-opus-5-5", daysAgo(30)),
315
+ entry("anthropic", "claude-opus-6", daysAgo(20), false),
316
+ entry("anthropic", "claude-opus-6-preview", daysAgo(20)),
317
+ ]);
318
+ const result = await resolveTaskModel({ ...base, model: "latest:anthropic/opus" });
319
+ expect(result?.resolvedModel).toBe("claude-opus-5-5");
320
+ });
321
+
322
+ test("overlay writes and catalog reloads reach alias resolution without waiting out the cache", async () => {
323
+ const alias = { ...base, model: "latest:anthropic/opus@any", record: false };
324
+ expect((await resolveTaskModel(alias))?.resolvedModel).toBe("claude-opus-5-5");
325
+
326
+ // No invalidateTierResolutionCatalog() from here on: the store's own writes must do it.
327
+ await upsertModelCatalogOverlay({
328
+ provider: "anthropic",
329
+ modelId: "claude-opus-6",
330
+ releaseDate: daysAgo(20),
331
+ pricing: { input: 5, output: 25 },
332
+ reason: "test overlay",
333
+ });
334
+ expect((await resolveTaskModel(alias))?.resolvedModel).toBe("claude-opus-6");
335
+
336
+ await deleteModelCatalogOverlay("anthropic", "claude-opus-6");
337
+ expect((await resolveTaskModel(alias))?.resolvedModel).toBe("claude-opus-5-5");
338
+
339
+ await replaceModelCatalog([
340
+ entry("anthropic", "claude-opus-5-5", daysAgo(30)),
341
+ entry("anthropic", "claude-opus-7", daysAgo(5)),
342
+ ]);
343
+ await reloadModelsCatalog();
344
+ expect((await resolveTaskModel(alias))?.resolvedModel).toBe("claude-opus-7");
345
+ });
346
+
347
+ test("MODEL_AUTO_UPGRADE=false freezes an alias at its last resolution", async () => {
348
+ await seedCatalog([entry("anthropic", "claude-opus-5-5", daysAgo(30))]);
349
+ const first = await resolveTaskModel({ ...base, model: "latest:anthropic/opus" });
350
+ expect(first?.resolvedModel).toBe("claude-opus-5-5");
351
+
352
+ await seedCatalog([
353
+ entry("anthropic", "claude-opus-5-5", daysAgo(30)),
354
+ entry("anthropic", "claude-opus-6", daysAgo(10)),
355
+ ]);
356
+ process.env.MODEL_AUTO_UPGRADE = "false";
357
+ const frozen = await resolveTaskModel({ ...base, model: "latest:anthropic/opus" });
358
+ expect(frozen?.resolvedModel).toBe("claude-opus-5-5");
359
+
360
+ delete process.env.MODEL_AUTO_UPGRADE;
361
+ const moved = await resolveTaskModel({ ...base, model: "latest:anthropic/opus" });
362
+ expect(moved?.resolvedModel).toBe("claude-opus-6");
363
+ const changes = await getDbClient().query<{ previousModel: string | null; newModel: string }>(
364
+ "SELECT previousModel, newModel FROM model_alias_resolutions ORDER BY id",
365
+ );
366
+ expect(changes).toEqual([
367
+ { previousModel: null, newModel: "claude-opus-5-5" },
368
+ { previousModel: "claude-opus-5-5", newModel: "claude-opus-6" },
369
+ ]);
370
+ });
371
+
372
+ test("an alias that resolves to nothing falls through to the next layer", async () => {
373
+ const result = await resolveTaskModel({
374
+ ...base,
375
+ env: { MODEL_TIER_CLAUDE_SMART: "latest:anthropic/nosuchfamily" },
376
+ });
377
+ expect(result).toEqual({
378
+ resolvedModel: "opus",
379
+ modelSource: "tier-default",
380
+ modelAlias: null,
381
+ });
382
+ });
383
+ });
384
+
385
+ describe("previewModelTiers", () => {
386
+ test("lists every provider tier with its default, config value and resolution", async () => {
387
+ const rows = await previewModelTiers({
388
+ env: {
389
+ MODEL_TIER_CLAUDE_SMART: "latest:anthropic/opus",
390
+ MODEL_TIER_CODEX_SMOL: "gpt-5.6-luna",
391
+ },
392
+ });
393
+
394
+ const claudeSmart = rows.find((r) => r.provider === "claude" && r.tier === "smart");
395
+ expect(claudeSmart).toEqual({
396
+ provider: "claude",
397
+ tier: "smart",
398
+ key: "MODEL_TIER_CLAUDE_SMART",
399
+ defaultValue: "opus",
400
+ configured: "latest:anthropic/opus",
401
+ source: "tier-config",
402
+ resolvedModel: "claude-opus-5-5",
403
+ alias: "latest:anthropic/opus",
404
+ });
405
+
406
+ const claudeSmol = rows.find((r) => r.provider === "claude" && r.tier === "smol");
407
+ expect(claudeSmol).toMatchObject({
408
+ configured: null,
409
+ source: "tier-default",
410
+ resolvedModel: "haiku",
411
+ alias: null,
412
+ });
413
+
414
+ const codexSmol = rows.find((r) => r.provider === "codex" && r.tier === "smol");
415
+ expect(codexSmol).toMatchObject({ source: "tier-config", resolvedModel: "gpt-5.6-luna" });
416
+
417
+ // acp has no portable tier mapping, so it has nothing to preview.
418
+ expect(rows.some((r) => r.provider === "acp")).toBe(false);
419
+ expect(new Set(rows.map((r) => r.provider)).size).toBe(7);
420
+ expect(rows).toHaveLength(28);
421
+ });
422
+
423
+ test("an alias that resolves to nothing reports the default it falls back to", async () => {
424
+ const rows = await previewModelTiers({
425
+ env: { MODEL_TIER_CLAUDE_SMART: "latest:anthropic/nosuchfamily" },
426
+ });
427
+ const row = rows.find((r) => r.provider === "claude" && r.tier === "smart");
428
+ expect(row).toMatchObject({
429
+ configured: "latest:anthropic/nosuchfamily",
430
+ source: "tier-default",
431
+ resolvedModel: "opus",
432
+ alias: null,
433
+ });
434
+ });
435
+
436
+ test("previewing does not record alias resolutions", async () => {
437
+ await previewModelTiers({ env: { MODEL_TIER_CLAUDE_SMART: "latest:anthropic/opus" } });
438
+ const count = await getDbClient().get<{ n: number }>(
439
+ "SELECT COUNT(*) AS n FROM model_alias_resolutions",
440
+ );
441
+ expect(count?.n).toBe(0);
442
+ });
443
+ });
444
+
445
+ describe("GET /api/models-catalog/tiers", () => {
446
+ test("serves the preview with the process env layered over the defaults", async () => {
447
+ process.env.MODEL_TIER_CLAUDE_SMART = "latest:anthropic/opus";
448
+ let status = 0;
449
+ let bodyStr = "";
450
+ const req = {
451
+ method: "GET",
452
+ url: "/api/models-catalog/tiers",
453
+ headers: {},
454
+ } as unknown as Parameters<typeof handleModelsCatalog>[0];
455
+ const res = {
456
+ setHeader() {},
457
+ writeHead(code: number) {
458
+ status = code;
459
+ },
460
+ end(body?: string) {
461
+ bodyStr = body ?? "";
462
+ },
463
+ } as unknown as Parameters<typeof handleModelsCatalog>[1];
464
+
465
+ const handled = await handleModelsCatalog(
466
+ req,
467
+ res,
468
+ ["api", "models-catalog", "tiers"],
469
+ new URLSearchParams(),
470
+ );
471
+
472
+ expect(handled).toBe(true);
473
+ expect(status).toBe(200);
474
+ const body = JSON.parse(bodyStr) as { tiers: { provider: string; tier: string }[] };
475
+ expect(body.tiers).toHaveLength(28);
476
+ expect(body.tiers.find((t) => t.provider === "claude" && t.tier === "smart")).toMatchObject({
477
+ key: "MODEL_TIER_CLAUDE_SMART",
478
+ source: "tier-config",
479
+ resolvedModel: "claude-opus-5-5",
480
+ alias: "latest:anthropic/opus",
481
+ });
482
+ });
483
+ });
484
+
485
+ describe("config keys and overrides parsing", () => {
486
+ test("tier config keys and validator", () => {
487
+ expect(tierConfigKey("claude-managed", "smart")).toBe("MODEL_TIER_CLAUDE_MANAGED_SMART");
488
+ expect(isTierConfigKey("MODEL_TIER_CLAUDE_SMART")).toBe(true);
489
+ expect(isTierConfigKey("MODEL_TIER_SMART")).toBe(false);
490
+ expect(isTierConfigKey("MODEL_TIER_NOPE_SMART")).toBe(false);
491
+ expect(validateConfigValue("MODEL_TIER_CLAUDE_SMART", "opus")).toBeNull();
492
+ expect(
493
+ validateConfigValue("MODEL_TIER_CODEX_SMART", "latest:openai/gpt-5.*@stable"),
494
+ ).toBeNull();
495
+ expect(validateConfigValue("MODEL_TIER_CLAUDE_SMART", "latest:bogus/x")).not.toBeNull();
496
+ expect(validateConfigValue("MODEL_TIER_CLAUDE_SMART", " ")).not.toBeNull();
497
+ expect(validateConfigValue("MODEL_LATEST_SOAK_DAYS", "0")).toBeNull();
498
+ expect(validateConfigValue("MODEL_LATEST_SOAK_DAYS", "-1")).not.toBeNull();
499
+ expect(validateConfigValue("MODEL_AUTO_UPGRADE", "false")).toBeNull();
500
+ expect(validateConfigValue("MODEL_AUTO_UPGRADE", "maybe")).not.toBeNull();
501
+ });
502
+
503
+ test("worker overrides: direct env beats MODEL_TIER_MAP; header is sanitized", () => {
504
+ expect(
505
+ parseWorkerModelTierOverrides(
506
+ { MODEL_TIER_MAP: '{"smart":"opus","smol":"haiku"}', MODEL_TIER_SMART: "sonnet" },
507
+ "claude",
508
+ ),
509
+ ).toEqual({ claude: { smart: "sonnet", smol: "haiku" } });
510
+ expect(parseWorkerModelTierOverrides({}, "codex")).toEqual({});
511
+ expect(
512
+ parseModelTierOverridesHeader(
513
+ header({ claude: { smart: "sonnet", bogus: "x" }, notaprovider: { smart: "y" } }),
514
+ ),
515
+ ).toEqual({ claude: { smart: "sonnet" } });
516
+ expect(parseModelTierOverridesHeader("%%%")).toBeUndefined();
517
+ expect(parseModelTierOverridesHeader(undefined)).toBeUndefined();
518
+ });
519
+ });
@@ -58,7 +58,7 @@ describe("buildModelsCatalog", () => {
58
58
  expect(model).toEqual({
59
59
  id: "test-vendor/test-model-live-only",
60
60
  name: "Test Model (live only)",
61
- cost: { input: 0.5, output: 1.5 },
61
+ cost: { input: 0.5, output: 1.5, cache_read: 0.1 },
62
62
  limit: { context: 256_000 },
63
63
  reasoning: true,
64
64
  reasoning_options: [{ type: "effort", values: ["low", "medium", "high"] }],
@@ -6,7 +6,7 @@ import {
6
6
  type Server,
7
7
  type ServerResponse,
8
8
  } from "node:http";
9
- import { closeDb, initDb } from "../be/db";
9
+ import { closeDb, createAgent, getDbClient, initDb } from "../be/db";
10
10
  import { handleCore } from "../http/core";
11
11
  import { handleStats } from "../http/stats";
12
12
  import { getPathSegments, parseQueryParams } from "../http/utils";
@@ -162,4 +162,68 @@ describe("MULTI_RUNTIME_ENABLED on the authenticated stats endpoint", () => {
162
162
  expect(stats.body.tasks).toBeDefined();
163
163
  });
164
164
  });
165
+
166
+ test("counts non-extension agent statuses without parsing profile fields", async () => {
167
+ const baseline = (await api("GET", "/api/stats")).body.agents as {
168
+ total: number;
169
+ idle: number;
170
+ busy: number;
171
+ offline: number;
172
+ };
173
+ const malformedMetadataAgent = await createAgent({
174
+ name: "stats-idle",
175
+ isLead: false,
176
+ status: "idle",
177
+ });
178
+ await getDbClient().run("UPDATE agents SET capabilities = 'not-json' WHERE id = ?", [
179
+ malformedMetadataAgent.id,
180
+ ]);
181
+ for (const [name, status] of [
182
+ ["stats-busy", "busy"],
183
+ ["stats-offline", "offline"],
184
+ ["stats-waiting", "waiting_for_credentials"],
185
+ ] as const) {
186
+ await createAgent({ name, isLead: false, status });
187
+ }
188
+ const extension = await createAgent({
189
+ id: "ext:stats-test",
190
+ name: "stats extension",
191
+ isLead: false,
192
+ status: "idle",
193
+ });
194
+ await getDbClient().run("UPDATE agents SET role = 'extension' WHERE id = ?", [extension.id]);
195
+
196
+ const stats = await api("GET", "/api/stats");
197
+
198
+ expect(stats.body.agents).toEqual({
199
+ total: baseline.total + 4,
200
+ idle: baseline.idle + 1,
201
+ busy: baseline.busy + 1,
202
+ offline: baseline.offline + 1,
203
+ });
204
+ });
205
+ });
206
+
207
+ describe("SWARM_DEV_MODE on the authenticated stats endpoint", () => {
208
+ test("defaults off and reflects changes without exposing the flag on health", async () => {
209
+ const previous = process.env.SWARM_DEV_MODE;
210
+ try {
211
+ for (const [raw, expected] of [
212
+ [undefined, false],
213
+ ["true", true],
214
+ ["false", false],
215
+ ["1", true],
216
+ ["0", false],
217
+ ["invalid", false],
218
+ ] as const) {
219
+ restoreEnv("SWARM_DEV_MODE", raw);
220
+ const stats = await api("GET", "/api/stats");
221
+ expect(stats.status).toBe(200);
222
+ expect(stats.body.devMode).toBe(expected);
223
+ expect((await api("GET", "/health")).body.devMode).toBeUndefined();
224
+ }
225
+ } finally {
226
+ restoreEnv("SWARM_DEV_MODE", previous);
227
+ }
228
+ });
165
229
  });