@desplega.ai/agent-swarm 2.4.0 → 2.4.1

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 (222) hide show
  1. package/README.md +1 -1
  2. package/dist/{acp-adapter-kedstxx6.js → acp-adapter-zvzr538d.js} +9 -9
  3. package/dist/{actions-vget6r1k.js → actions-jhtqdw69.js} +21 -21
  4. package/dist/{amp-adapter-xeb5mktv.js → amp-adapter-bd84rhs2.js} +4 -4
  5. package/dist/{app-mqap8t8m.js → app-kreq1gwg.js} +3 -3
  6. package/dist/{assistant-h0gv97mv.js → assistant-3k22sbsq.js} +22 -22
  7. package/dist/{boot-reembed-qr8a894z.js → boot-reembed-e53x6b9v.js} +1 -1
  8. package/dist/{boot-reembed-rj85jjhw.js → boot-reembed-f6z923ny.js} +1 -1
  9. package/dist/{boot-scrub-sweep-eyfvn6v9.js → boot-scrub-sweep-0538vsb7.js} +4 -4
  10. package/dist/{boot-seeds-xs9a8dx1.js → boot-seeds-6wxqkfk2.js} +4 -4
  11. package/dist/{claude-adapter-bm23cye7.js → claude-adapter-gfts5044.js} +9 -9
  12. package/dist/{claude-managed-adapter-1mwrsm9d.js → claude-managed-adapter-h2y889ge.js} +2 -2
  13. package/dist/{claude-managed-setup-gkfgks4j.js → claude-managed-setup-h00hsgtz.js} +2 -2
  14. package/dist/{claude-sdk-session-apb4ypz3.js → claude-sdk-session-56besmfz.js} +13 -13
  15. package/dist/{cli-nrrg0fvy.js → cli-0bh477zn.js} +18 -5
  16. package/dist/{cli-7gh7020n.js → cli-1dmcq2eh.js} +1 -1
  17. package/dist/{cli-7ayprxp7.js → cli-2dfbv8qr.js} +8 -0
  18. package/dist/{cli-1cevz46c.js → cli-3kh4kpqy.js} +6 -6
  19. package/dist/{cli-tg9ksbn8.js → cli-3p8fqcnh.js} +2 -2
  20. package/dist/{cli-x1p5dhq2.js → cli-3r448dqn.js} +3 -3
  21. package/dist/{cli-ecshf504.js → cli-3tnpj2bh.js} +7 -7
  22. package/dist/{cli-ffbrkh1r.js → cli-471qk6s2.js} +46 -38
  23. package/dist/{cli-a49vpbf3.js → cli-5kqnemen.js} +1 -1
  24. package/dist/{cli-zvze9rbm.js → cli-6ncjvx55.js} +3 -3
  25. package/dist/{cli-fb6t92v8.js → cli-6x9ma5w5.js} +1 -1
  26. package/dist/{cli-c8fwpgqb.js → cli-7225gwj7.js} +2 -2
  27. package/dist/{cli-bn26xbcs.js → cli-8nyhf7za.js} +1 -1
  28. package/dist/{cli-y83zk5sb.js → cli-a0kcty51.js} +4 -4
  29. package/dist/{cli-8b9wvjps.js → cli-a4gvwttf.js} +4 -4
  30. package/dist/{cli-jhp7w1fy.js → cli-a5ebc95n.js} +2 -2
  31. package/dist/{cli-mz1ezzzh.js → cli-ash6xxk7.js} +2 -2
  32. package/dist/{cli-rsd7ga7v.js → cli-axh29tyr.js} +11 -11
  33. package/dist/{cli-p22zdvnt.js → cli-bn0rewsy.js} +3 -3
  34. package/dist/{cli-6ftrrwm1.js → cli-bn5kevnf.js} +5 -5
  35. package/dist/{cli-p1m2b6gc.js → cli-d2mb5s8a.js} +7 -7
  36. package/dist/{cli-c5sbmw1v.js → cli-d82btycs.js} +94 -15
  37. package/dist/{cli-vjfpshn3.js → cli-ejs3m1sx.js} +1 -1
  38. package/dist/{cli-a1esmmee.js → cli-fpmsqq59.js} +7 -2
  39. package/dist/{cli-8cxkw1f5.js → cli-fz9kewbh.js} +2 -2
  40. package/dist/{cli-8rwvksbc.js → cli-g0hgtz4p.js} +5 -5
  41. package/dist/{cli-kz116kvw.js → cli-gf0kfg81.js} +7 -7
  42. package/dist/{cli-eqgfs202.js → cli-hqhzg1qq.js} +2 -2
  43. package/dist/{cli-3wjs22x3.js → cli-j4rqdf2n.js} +2 -2
  44. package/dist/{cli-ywyrffff.js → cli-jh0qcrga.js} +1 -1
  45. package/dist/{cli-ttaz6c7m.js → cli-k9z4zd4p.js} +6 -5
  46. package/dist/{cli-nme7wery.js → cli-kmqgmcnb.js} +46 -13
  47. package/dist/{cli-x719j3vs.js → cli-kr2pmfqx.js} +392 -168
  48. package/dist/{cli-wrv09v7q.js → cli-m2qgh5sv.js} +1 -1
  49. package/dist/{cli-xfe63zx3.js → cli-mfe7f7b5.js} +2 -2
  50. package/dist/{cli-x3en6ct3.js → cli-nwyw3zhg.js} +1 -1
  51. package/dist/{cli-5s5hx57y.js → cli-nx462e4v.js} +1 -1
  52. package/dist/{cli-bfc3k1x5.js → cli-p0rakbrk.js} +9 -9
  53. package/dist/{cli-avmqhatn.js → cli-p6argxh9.js} +8 -8
  54. package/dist/{cli-3avfh5az.js → cli-pemywhfq.js} +2 -2
  55. package/dist/{cli-sppc0cmw.js → cli-pnp2j5zg.js} +2 -2
  56. package/dist/{cli-wk8ccrz6.js → cli-pw7v69vp.js} +2 -2
  57. package/dist/{cli-bhwa8rzc.js → cli-rdq7tzwm.js} +13 -12
  58. package/dist/{cli-rg7ntypm.js → cli-rqqsfz30.js} +379 -87
  59. package/dist/{cli-kk2r80n5.js → cli-s48yvzb8.js} +4 -4
  60. package/dist/{cli-zv4vy2b0.js → cli-s95dknxh.js} +1 -1
  61. package/dist/{cli-b80a1100.js → cli-sjyetj21.js} +2 -2
  62. package/dist/{cli-rdr166ht.js → cli-smvcw9z5.js} +1 -1
  63. package/dist/{cli-2rwet3r7.js → cli-sv5gb0yb.js} +1 -1
  64. package/dist/{cli-5ff2sjyt.js → cli-syhq378v.js} +2 -2
  65. package/dist/{cli-tj4j2ts4.js → cli-tbhwrxpt.js} +8 -8
  66. package/dist/{cli-4fhyq711.js → cli-vy0fay6k.js} +13 -13
  67. package/dist/{cli-ptkpaqfy.js → cli-wt645v7q.js} +1 -1
  68. package/dist/{cli-f3nx1h8z.js → cli-wxbn3yfx.js} +2 -2
  69. package/dist/{cli-wyrr38xm.js → cli-x5mq1fyq.js} +1 -1
  70. package/dist/{cli-ccn0eybf.js → cli-xbzvet96.js} +6 -6
  71. package/dist/{cli-4mkvwgzr.js → cli-xf234ws8.js} +1 -1
  72. package/dist/{cli-tgvpmnwz.js → cli-xftrvxag.js} +5 -5
  73. package/dist/{cli-02vhhssk.js → cli-z071fb95.js} +5 -5
  74. package/dist/{cli-mvg2hf8p.js → cli-z1ew8vbq.js} +14 -7
  75. package/dist/{cli-dsgj3e7a.js → cli-zfg9enh9.js} +2 -2
  76. package/dist/{cli-yxqag76b.js → cli-zw88zjba.js} +1 -1
  77. package/dist/cli.js +19 -19
  78. package/dist/{codex-adapter-pp2gq8gr.js → codex-adapter-p6rmm7vv.js} +6 -6
  79. package/dist/{codex-hook-qwzccd64.js → codex-hook-x7nejyrr.js} +7 -4
  80. package/dist/{codex-login-35p4sk85.js → codex-login-m819c41r.js} +1 -1
  81. package/dist/{codex-session-runner-0vzn09t8.js → codex-session-runner-8vd5t9t0.js} +7 -7
  82. package/dist/{commands-psqhkttb.js → commands-7ems2p13.js} +2 -2
  83. package/dist/{controller-t20vz2k8.js → controller-m263fg43.js} +4 -4
  84. package/dist/{controllers-connect-gkxmqw9d.js → controllers-connect-bg33ae53.js} +23 -23
  85. package/dist/{cursor-adapter-3v542qe5.js → cursor-adapter-cz6nm6mj.js} +3 -3
  86. package/dist/{db-2xbyenjr.js → db-w505dwa8.js} +2 -2
  87. package/dist/{db-nc1rd869.js → db-ys2rbdft.js} +3 -3
  88. package/dist/{devin-adapter-7egzmt0k.js → devin-adapter-zgqadvnm.js} +1 -1
  89. package/dist/{dispatcher-ewceb8ts.js → dispatcher-0s0mckqr.js} +20 -20
  90. package/dist/{docker-njavd3fb.js → docker-fkgtr75t.js} +2 -2
  91. package/dist/{dsh-adapter-75xwh78t.js → dsh-adapter-3n3net33.js} +3 -3
  92. package/dist/{e2b-5r6a5jeb.js → e2b-7am6tmq6.js} +2 -2
  93. package/dist/{e2b-pw1nvtmh.js → e2b-thdcxtjd.js} +2 -2
  94. package/dist/{easter-egg-3d-xsc47jwf.js → easter-egg-3d-5cgynaja.js} +1 -1
  95. package/dist/{event-bus-101fenkh.js → event-bus-9vpk8gmq.js} +1 -1
  96. package/dist/{execute-gzmw1mnw.js → execute-t4e6m3rw.js} +1 -1
  97. package/dist/{external-channel-a5dsmxn4.js → external-channel-5b23fv8z.js} +2 -2
  98. package/dist/{grok-adapter-vj88xr23.js → grok-adapter-q9fghe5q.js} +10 -10
  99. package/dist/handlers-mqrym347.js +39 -0
  100. package/dist/{heartbeat-t2m9r3wa.js → heartbeat-tww6yx1w.js} +23 -23
  101. package/dist/{hook-xa677f8r.js → hook-dx0pkvar.js} +16 -9
  102. package/dist/{hook-8femx1bg.js → hook-pvmh9b6m.js} +6 -6
  103. package/dist/{http-xpmmq028.js → http-k2ejbt05.js} +199 -131
  104. package/dist/{index-s7ckdajq.js → index-2z710v4x.js} +10 -10
  105. package/dist/{index-dg2tmj99.js → index-dgz61nff.js} +20 -20
  106. package/dist/{index-syztqdt6.js → index-g6tbb6ve.js} +1 -1
  107. package/dist/index-h2ek08d6.js +40 -0
  108. package/dist/index-pqfwh9nn.js +41 -0
  109. package/dist/{index-cdzyfrsp.js → index-x5aktmp8.js} +1 -1
  110. package/dist/{keepalive-3nr4st3r.js → keepalive-nhvpw10z.js} +4 -4
  111. package/dist/lead-jb6sf69z.js +78 -0
  112. package/dist/{maintenance-verwpe2c.js → maintenance-agakc519.js} +1 -1
  113. package/dist/{oauth-refresh-sweep-9q2t0y3q.js → oauth-refresh-sweep-4dcbhtn0.js} +3 -3
  114. package/dist/{onboard-aytqedmq.js → onboard-4n2kwa4v.js} +3 -3
  115. package/dist/{opencode-adapter-f4hryjny.js → opencode-adapter-3s7ax02e.js} +7 -7
  116. package/dist/{otel-impl-hckw4wt1.js → otel-impl-tfa12080.js} +2 -2
  117. package/dist/{pi-mono-adapter-hxby0ce1.js → pi-mono-adapter-ytp7gz8b.js} +22 -11
  118. package/dist/{pricing-refresh-5nvme3sm.js → pricing-refresh-3cqx3f3x.js} +3 -3
  119. package/dist/{rbac-roles-pd8c26m1.js → rbac-roles-9fae5rnv.js} +1 -1
  120. package/dist/{rbac-roles-vbpy21mg.js → rbac-roles-y6zbfzve.js} +2 -2
  121. package/dist/{render-v2-7nqgvk2q.js → render-v2-emawjvc4.js} +3 -3
  122. package/dist/{resume-b6djcj3c.js → resume-zhgts7h7.js} +10 -8
  123. package/dist/{runtime-txrxc9za.js → runtime-xe3aqy4x.js} +1 -1
  124. package/dist/{session-access-fc3f9y12.js → session-access-cm823e06.js} +4 -4
  125. package/dist/{setup-n39szm6b.js → setup-tkp99w1e.js} +1 -1
  126. package/dist/{virtual-modules-wyp2k6z3.js → virtual-modules-4r3zkc20.js} +2 -2
  127. package/dist/{work-objects-jem2fre0.js → work-objects-f722h263.js} +23 -23
  128. package/dist/worker-p7f336rq.js +78 -0
  129. package/dist/{x-8ewt8gxn.js → x-y45mthwn.js} +2 -2
  130. package/openapi.json +99 -52
  131. package/package.json +2 -2
  132. package/src/apps/action-runner.ts +146 -0
  133. package/src/apps/row-store.ts +4 -2
  134. package/src/apps/script-types.ts +1 -1
  135. package/src/be/db.ts +14 -4
  136. package/src/be/fleet-benchmark-stats.ts +10 -5
  137. package/src/be/models-catalog.ts +4 -0
  138. package/src/be/onboarding.ts +6 -0
  139. package/src/be/scripts/typecheck.ts +13 -0
  140. package/src/be/swarm-config-guard.ts +10 -1
  141. package/src/be/telemetry-enable.ts +1 -1
  142. package/src/commands/provider-credentials.ts +12 -2
  143. package/src/commands/runner.ts +3 -0
  144. package/src/heartbeat/heartbeat.ts +2 -2
  145. package/src/hooks/codex-hook.ts +6 -2
  146. package/src/hooks/hook.ts +7 -0
  147. package/src/hooks/pr-body-guard.ts +38 -11
  148. package/src/hooks/pr-room-guard.ts +88 -0
  149. package/src/http/agents.ts +1 -0
  150. package/src/http/apps.ts +84 -181
  151. package/src/http/fleet-benchmark.ts +79 -30
  152. package/src/http/global-config.ts +7 -0
  153. package/src/http/ranking.ts +24 -40
  154. package/src/http/tasks.ts +2 -1
  155. package/src/http/telemetry-ui.ts +1 -1
  156. package/src/http/used-by.ts +55 -19
  157. package/src/http/users.ts +2 -2
  158. package/src/providers/amp-adapter.ts +1 -1
  159. package/src/providers/pi-mono-adapter.ts +15 -0
  160. package/src/providers/reasoning-effort.ts +2 -1
  161. package/src/providers/types.ts +2 -0
  162. package/src/ranking/telemetry-ranking.ts +5 -33
  163. package/src/scripts-runtime/sdk-allowlist.ts +7 -0
  164. package/src/scripts-runtime/types/stdlib.d.ts +31 -0
  165. package/src/scripts-runtime/types/swarm-sdk.d.ts +31 -0
  166. package/src/server.ts +4 -0
  167. package/src/telemetry-context.ts +19 -1
  168. package/src/telemetry-contract/EVENTS.md +42 -8
  169. package/src/telemetry-contract/SOURCE.json +6 -5
  170. package/src/telemetry-contract/catalog.json +225 -5
  171. package/src/telemetry-contract/ranking/fixtures/valid/no-claim.json +10 -0
  172. package/src/telemetry-contract/ranking/listing.v1.schema.json +2 -2
  173. package/src/telemetry-contract/types.gen.ts +225 -27
  174. package/src/telemetry-identity.ts +47 -9
  175. package/src/telemetry-snapshot.ts +9 -1
  176. package/src/telemetry-ui-events.ts +9 -2
  177. package/src/telemetry.ts +14 -3
  178. package/src/tests/app-action-run-tool.test.ts +178 -0
  179. package/src/tests/app-row-tools.test.ts +360 -0
  180. package/src/tests/check-pr-body.test.ts +0 -23
  181. package/src/tests/credential-check.test.ts +38 -0
  182. package/src/tests/fleet-benchmark.test.ts +122 -2
  183. package/src/tests/http-users.test.ts +62 -1
  184. package/src/tests/model-catalog-table.test.ts +63 -0
  185. package/src/tests/model-profiles-api.test.ts +19 -7
  186. package/src/tests/models-catalog.test.ts +50 -0
  187. package/src/tests/onboarding.test.ts +21 -0
  188. package/src/tests/org-website.test.ts +62 -0
  189. package/src/tests/pi-mono-adapter.test.ts +23 -0
  190. package/src/tests/pr-body-leaks.test.ts +4 -0
  191. package/src/tests/pr-room-guard.test.ts +242 -0
  192. package/src/tests/ranking-listing.test.ts +8 -23
  193. package/src/tests/ranking-route.test.ts +49 -47
  194. package/src/tests/reasoning-effort.test.ts +39 -3
  195. package/src/tests/runner-harness-telemetry-env.test.ts +56 -1
  196. package/src/tests/swarm-tool-result-gate.test.ts +2 -4
  197. package/src/tests/telemetry-context.test.ts +128 -2
  198. package/src/tests/telemetry-ui-route.test.ts +123 -0
  199. package/src/tests/tool-annotations.test.ts +3 -2
  200. package/src/tests/used-by-route.test.ts +144 -10
  201. package/src/tests/workflow-agent-task-retry.test.ts +130 -4
  202. package/src/tests/workflow-wait-recovery.test.ts +116 -4
  203. package/src/tools/app-action-run.ts +63 -0
  204. package/src/tools/app-rows.ts +234 -0
  205. package/src/tools/model-catalog/index.ts +1 -1
  206. package/src/tools/tool-config.ts +8 -0
  207. package/src/tools/utils.ts +1 -1
  208. package/src/tools/workflows/retry-workflow-run.ts +1 -1
  209. package/src/types.ts +17 -0
  210. package/src/utils/company-profile.ts +24 -0
  211. package/src/utils/org-website.ts +58 -21
  212. package/src/workflows/recovery.ts +10 -22
  213. package/src/workflows/resume.ts +16 -0
  214. package/src/workflows/task-step-routing.ts +47 -7
  215. package/templates/skills/apps/SKILL.md +41 -2
  216. package/templates/skills/apps/content.md +41 -2
  217. package/dist/handlers-zbs90bny.js +0 -39
  218. package/dist/index-8pwc1jc8.js +0 -40
  219. package/dist/index-nxa6me0q.js +0 -41
  220. package/dist/lead-td8h2sfn.js +0 -78
  221. package/dist/worker-wr6xgfr2.js +0 -78
  222. package/src/tests/check-ranking-entry.test.ts +0 -146
@@ -209,11 +209,37 @@ describe("GET /api/fleet-benchmark", () => {
209
209
  }
210
210
  }
211
211
 
212
- test("telemetry off: no proxy call and no local scan", async () => {
212
+ test("telemetry off: local 30-day counts without an ingest anchor or proxy call", async () => {
213
213
  process.env.ANONYMIZED_TELEMETRY = "false";
214
+ await db.getDbClient().run("DELETE FROM swarm_config WHERE key = 'telemetry_first_ingest'");
215
+ await addTasks(9, "completed", 3);
216
+ await addTasks(1, "failed", 3);
217
+ await addTasks(5, "pending", 3);
218
+ await addTasks(40, "completed", 45);
219
+ await setProfile();
214
220
  const response = await buildFleetBenchmarkResponse(NOW);
215
221
  expect(response.state).toBe("telemetry_off");
216
- expect(response.local).toBeNull();
222
+ expect(response.local).toEqual({
223
+ tasksPerWeek: 3.5,
224
+ taskSuccessRate: 0.9,
225
+ finishedTasks: 10,
226
+ agents: 3,
227
+ bucket: "2-5",
228
+ });
229
+ expect(response.level).toBe("full");
230
+ expect(response.peerGroup).toBeNull();
231
+ expect(response.comparisons).toEqual([]);
232
+ expect(fetched).toEqual([]);
233
+ });
234
+
235
+ test("telemetry off on a fresh swarm returns zero counts", async () => {
236
+ process.env.ANONYMIZED_TELEMETRY = "false";
237
+ await db.getDbClient().run("DELETE FROM swarm_config WHERE key = 'telemetry_first_ingest'");
238
+ expect(await buildFleetBenchmarkResponse(NOW)).toMatchObject({
239
+ state: "telemetry_off",
240
+ level: "basic",
241
+ local: { tasksPerWeek: 0, taskSuccessRate: null, finishedTasks: 0 },
242
+ });
217
243
  expect(fetched).toEqual([]);
218
244
  });
219
245
 
@@ -227,6 +253,19 @@ describe("GET /api/fleet-benchmark", () => {
227
253
  expect(fetched).toEqual([]);
228
254
  });
229
255
 
256
+ test("an empty stored name overrides the deployment name", async () => {
257
+ const saved = process.env.SWARM_ORG_NAME;
258
+ process.env.SWARM_ORG_NAME = "Deployment name";
259
+ try {
260
+ await db.upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_NAME", value: "" });
261
+ expect((await buildFleetBenchmarkResponse(NOW)).state).toBe("no_org_name");
262
+ expect(fetched).toEqual([]);
263
+ } finally {
264
+ if (saved === undefined) delete process.env.SWARM_ORG_NAME;
265
+ else process.env.SWARM_ORG_NAME = saved;
266
+ }
267
+ });
268
+
230
269
  test("fewer than 10 finished tasks: local numbers only", async () => {
231
270
  await addTasks(9, "completed", 3);
232
271
  await addTasks(5, "pending", 3);
@@ -242,6 +281,8 @@ describe("GET /api/fleet-benchmark", () => {
242
281
  await addTasks(40, "completed", 45); // Outside the window.
243
282
  const response = await buildFleetBenchmarkResponse(NOW);
244
283
  expect(response.state).toBe("ready");
284
+ expect(response.level).toBe("basic");
285
+ expect(response.peerGroup).toBeNull();
245
286
  expect(response.local).toEqual({
246
287
  tasksPerWeek: 14,
247
288
  taskSuccessRate: 0.95,
@@ -259,6 +300,85 @@ describe("GET /api/fleet-benchmark", () => {
259
300
  expect(fetched).toHaveLength(1);
260
301
  });
261
302
 
303
+ async function setProfile(teamSize = "2-10", useCase = "features"): Promise<void> {
304
+ await db.upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_WEBSITE", value: "acme.com" });
305
+ await db.upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_TEAM_SIZE", value: teamSize });
306
+ await db.upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_USE_CASE", value: useCase });
307
+ }
308
+
309
+ test("full profile requests peers and returns the proxy group", async () => {
310
+ await addTasks(10, "completed", 1);
311
+ await setProfile();
312
+ proxyBody = {
313
+ ...FLEET,
314
+ peer_group: { team_size: "2-10", use_case: "features", orgs: 6 },
315
+ };
316
+ const response = await buildFleetBenchmarkResponse(NOW);
317
+ expect(response).toMatchObject({
318
+ state: "ready",
319
+ level: "full",
320
+ peerGroup: { teamSize: "2-10", useCase: "features", orgs: 6 },
321
+ });
322
+ expect(fetched).toEqual([
323
+ "http://proxy.test/v1/fleet/benchmarks?window=30d&team_size=2-10&use_case=features",
324
+ ]);
325
+ await buildFleetBenchmarkResponse(NOW);
326
+ expect(fetched).toHaveLength(1);
327
+ });
328
+
329
+ test("full profile tolerates absent or null peer_group from the proxy", async () => {
330
+ await addTasks(10, "completed", 1);
331
+ await setProfile();
332
+ for (const peer_group of [undefined, null]) {
333
+ proxyBody = { ...FLEET, peer_group };
334
+ const route = await freshRoute();
335
+ const response = await route.buildFleetBenchmarkResponse(NOW);
336
+ expect(response).toMatchObject({ state: "ready", level: "full", peerGroup: null });
337
+ }
338
+ });
339
+
340
+ test("cache follows profile changes and cleared fields return to basic", async () => {
341
+ await addTasks(10, "completed", 1);
342
+ await buildFleetBenchmarkResponse(NOW);
343
+ await setProfile();
344
+ await buildFleetBenchmarkResponse(NOW);
345
+ await setProfile("11-50", "bugs");
346
+ await buildFleetBenchmarkResponse(NOW);
347
+ await setProfile("11-50", "review");
348
+ await buildFleetBenchmarkResponse(NOW);
349
+ expect(fetched).toHaveLength(4);
350
+ expect(fetched[2]).toContain("team_size=11-50&use_case=bugs");
351
+ expect(fetched[3]).toContain("team_size=11-50&use_case=review");
352
+ await db.upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_WEBSITE", value: "" });
353
+ const response = await buildFleetBenchmarkResponse(NOW);
354
+ expect(response.level).toBe("basic");
355
+ expect(fetched.at(-1)).toBe("http://proxy.test/v1/fleet/benchmarks?window=30d");
356
+ });
357
+
358
+ test("partial profile stays basic and sends no peer filters", async () => {
359
+ await addTasks(10, "completed", 1);
360
+ for (const key of ["SWARM_ORG_WEBSITE", "SWARM_ORG_TEAM_SIZE", "SWARM_ORG_USE_CASE"]) {
361
+ await setProfile();
362
+ await db.upsertSwarmConfig({ scope: "global", key, value: "" });
363
+ const route = await freshRoute();
364
+ expect((await route.buildFleetBenchmarkResponse(NOW)).level).toBe("basic");
365
+ expect(fetched.at(-1)).toBe("http://proxy.test/v1/fleet/benchmarks?window=30d");
366
+ }
367
+ });
368
+
369
+ test("enabling telemetry never reuses local-only counts for comparison", async () => {
370
+ await addTasks(10, "completed", 1);
371
+ await db.getDbClient().run("DELETE FROM swarm_config WHERE key = 'telemetry_first_ingest'");
372
+ process.env.ANONYMIZED_TELEMETRY = "false";
373
+ expect((await buildFleetBenchmarkResponse(NOW)).local?.finishedTasks).toBe(10);
374
+ process.env.ANONYMIZED_TELEMETRY = "true";
375
+ expect(await buildFleetBenchmarkResponse(NOW)).toMatchObject({
376
+ state: "unavailable",
377
+ local: null,
378
+ });
379
+ expect(fetched).toEqual([]);
380
+ });
381
+
262
382
  async function mintInstallId(daysAgo: number): Promise<void> {
263
383
  await db
264
384
  .getDbClient()
@@ -160,12 +160,39 @@ describe("GET /api/users", () => {
160
160
  });
161
161
 
162
162
  describe("POST /api/users", () => {
163
+ test("email is optional, and valid email addresses are accepted", async () => {
164
+ for (const email of [undefined, "taras+swarm@example.com"]) {
165
+ const response = await authedFetch("/api/users", {
166
+ method: "POST",
167
+ body: JSON.stringify({ name: "Optional email", email, role: "engineer" }),
168
+ });
169
+ expect(response.status).toBe(200);
170
+ const { user } = await response.json();
171
+ expect(user.email ?? null).toBe(email ?? null);
172
+ expect(user.role).toBe("engineer");
173
+ }
174
+ });
175
+
176
+ test("invalid email is rejected before a user is created", async () => {
177
+ for (const email of ["", "not-an-email", "tester@dev", null]) {
178
+ const response = await authedFetch("/api/users", {
179
+ method: "POST",
180
+ body: JSON.stringify({ name: "Invalid email", email }),
181
+ });
182
+ expect(response.status).toBe(400);
183
+ expect(await response.text()).toContain("email");
184
+ }
185
+ expect(await getDbClient().get<{ n: number }>("SELECT COUNT(*) AS n FROM users")).toEqual({
186
+ n: 0,
187
+ });
188
+ });
189
+
163
190
  test("creates user + links identities + budget event, all tagged op:<fp>", async () => {
164
191
  const r = await authedFetch("/api/users", {
165
192
  method: "POST",
166
193
  body: JSON.stringify({
167
194
  name: "Tester",
168
- email: "tester@dev",
195
+ email: "tester@dev.example",
169
196
  dailyBudgetUsd: 5,
170
197
  identities: [
171
198
  { kind: "slack", externalId: "U_QA1" },
@@ -197,6 +224,40 @@ describe("POST /api/users", () => {
197
224
  });
198
225
 
199
226
  describe("PATCH /api/users/:id", () => {
227
+ test("validates email when present and permits profile updates without email", async () => {
228
+ const user = await createUser({ name: "Email validation", email: "old@example.com" });
229
+ for (const email of ["", "invalid", "tester@dev"]) {
230
+ const response = await authedFetch(`/api/users/${user.id}`, {
231
+ method: "PATCH",
232
+ body: JSON.stringify({ email }),
233
+ });
234
+ expect(response.status).toBe(400);
235
+ expect(await response.text()).toContain("email");
236
+ }
237
+ const unchanged = await authedFetch(`/api/users/${user.id}`);
238
+ expect((await unchanged.json()).user.email).toBe("old@example.com");
239
+ for (const patch of [{ role: "founder" }, { email: "new+swarm@example.com" }]) {
240
+ const response = await authedFetch(`/api/users/${user.id}`, {
241
+ method: "PATCH",
242
+ body: JSON.stringify(patch),
243
+ });
244
+ expect(response.status).toBe(200);
245
+ expect((await response.json()).user).toMatchObject(patch);
246
+ }
247
+ });
248
+
249
+ test("null clears the primary email", async () => {
250
+ const user = await createUser({ name: "Clear email", email: "clear@example.com" });
251
+ const response = await authedFetch(`/api/users/${user.id}`, {
252
+ method: "PATCH",
253
+ body: JSON.stringify({ email: null }),
254
+ });
255
+ expect(response.status).toBe(200);
256
+ expect((await response.json()).user.email ?? null).toBeNull();
257
+ const reloaded = await authedFetch(`/api/users/${user.id}`);
258
+ expect((await reloaded.json()).user.email ?? null).toBeNull();
259
+ });
260
+
200
261
  test("dailyBudgetUsd mirrors into user-scoped budgets and null removes the mirror", async () => {
201
262
  const create = await authedFetch("/api/users", {
202
263
  method: "POST",
@@ -1,5 +1,6 @@
1
1
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
+ import { reasoningLevelsForModel } from "@desplega/model-catalog";
3
4
  import { closeDb, getActivePricingRow, getDbClient, initDb } from "../be/db";
4
5
  import {
5
6
  listModelCatalogOverlay,
@@ -10,6 +11,7 @@ import {
10
11
  import { getModelsCatalog, resetModelsCatalogForTests } from "../be/models-catalog";
11
12
  import type { ModelsDevCache } from "../be/modelsdev-cache";
12
13
  import { MODEL_CATALOG_FRESH_MS, refreshModelCatalog } from "../be/pricing-refresh";
14
+ import { getContextWindowSize } from "../utils/context-window";
13
15
 
14
16
  const TEST_DB_PATH = "./test-model-catalog-table.sqlite";
15
17
 
@@ -212,6 +214,67 @@ describe("persistent model catalog", () => {
212
214
  expect(model?.name).toBe("GPT Base");
213
215
  });
214
216
 
217
+ test("opencode-go lands in the catalog, context windows and effort gating", async () => {
218
+ const cache: ModelsDevCache = {
219
+ "opencode-go": {
220
+ id: "opencode-go",
221
+ name: "OpenCode Go",
222
+ models: {
223
+ "deepseek-v4.1-flash": {
224
+ id: "deepseek-v4.1-flash",
225
+ name: "DeepSeek V4.1 Flash",
226
+ limit: { context: 1_000_000 },
227
+ reasoning: true,
228
+ reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }],
229
+ },
230
+ },
231
+ },
232
+ };
233
+ await refreshModelCatalog({ force: true, now: 1_000, fetchImpl: ok(cache, '"og-v1"') });
234
+
235
+ const catalog = await loadModelsCatalog();
236
+ expect(catalog.providers["opencode-go"]?.models["deepseek-v4.1-flash"]?.limit?.context).toBe(
237
+ 1_000_000,
238
+ );
239
+ // Before the projection covered opencode-go this fell back to 200k.
240
+ expect(getContextWindowSize("opencode-go/deepseek-v4.1-flash")).toBe(1_000_000);
241
+ // opencode keeps the model's own `max`; the catalog now carries the facts.
242
+ expect(
243
+ reasoningLevelsForModel("opencode", "opencode-go/deepseek-v4.1-flash", catalog.providers),
244
+ ).toEqual(["low", "high", "max"]);
245
+ });
246
+
247
+ test("an opencode-go overlay expires once upstream carries the projected facts", async () => {
248
+ const cache: ModelsDevCache = {
249
+ "opencode-go": {
250
+ id: "opencode-go",
251
+ name: "OpenCode Go",
252
+ models: {
253
+ "deepseek-v4.1-flash": {
254
+ id: "deepseek-v4.1-flash",
255
+ limit: { context: 1_000_000 },
256
+ reasoning: true,
257
+ },
258
+ },
259
+ },
260
+ };
261
+ await refreshModelCatalog({ force: true, now: 1_000, fetchImpl: ok(cache, '"og-v1"') });
262
+ await upsertModelCatalogOverlay({
263
+ provider: "opencode-go",
264
+ modelId: "deepseek-v4.1-flash",
265
+ contextWindow: 1_000_000,
266
+ reason: "hand-verified until models.dev caught up",
267
+ });
268
+
269
+ await refreshModelCatalog({ force: true, now: 2_000, fetchImpl: ok(cache, '"og-v2"') });
270
+
271
+ expect((await listModelCatalogOverlay()).map((o) => `${o.provider}/${o.modelId}`)).toEqual([]);
272
+ const model = (await loadModelsCatalog()).providers["opencode-go"]?.models[
273
+ "deepseek-v4.1-flash"
274
+ ];
275
+ expect(model?.limit?.context).toBe(1_000_000);
276
+ });
277
+
215
278
  test("overlay auto-expires once upstream matches", async () => {
216
279
  await refreshModelCatalog({ force: true, now: 1_000, fetchImpl: ok(payload(), '"v1"') });
217
280
  await upsertModelCatalogOverlay({
@@ -228,14 +228,26 @@ describe("profile CRUD", () => {
228
228
  (await call("PATCH", "/api/model-profiles/oc-off", { description: "kept" })).status,
229
229
  ).toBe(200);
230
230
 
231
+ // opencode passes the model's own declared efforts through, `max` included.
232
+ expect(
233
+ (await call("POST", "/api/model-profiles", { ...body, name: "oc-max", effort: "max" }))
234
+ .status,
235
+ ).toBe(201);
236
+
231
237
  // 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);
238
+ // `openai/gpt-5.1-codex` declares low|medium|high, so it accepts no `max`.
239
+ const noMax = { harness: "opencode", model: "openai/gpt-5.1-codex" };
240
+ expect(
241
+ (await call("POST", "/api/model-profiles", { ...noMax, name: "oc-nomax", effort: "max" }))
242
+ .status,
243
+ ).toBe(400);
244
+ expect(
245
+ (await call("POST", "/api/model-profiles", { ...noMax, name: "oc-capped", effort: "low" }))
246
+ .status,
247
+ ).toBe(201);
248
+ expect((await call("PATCH", "/api/model-profiles/oc-capped", { effort: "max" })).status).toBe(
249
+ 400,
250
+ );
239
251
  });
240
252
 
241
253
  test("a tier seed and a profile in use cannot be deleted", async () => {
@@ -74,6 +74,33 @@ describe("buildModelsCatalog", () => {
74
74
  });
75
75
  expect(catalog.openai?.models["gpt-keyed"]?.id).toBe("gpt-keyed");
76
76
  });
77
+
78
+ test("projects opencode-go (OpenCode Go) like its sibling opencode", () => {
79
+ const catalog = buildModelsCatalog({
80
+ requesty: { models: { ignored: { id: "ignored" } } },
81
+ "opencode-go": {
82
+ id: "opencode-go",
83
+ name: "OpenCode Go",
84
+ models: {
85
+ "deepseek-v4.1-flash": {
86
+ id: "deepseek-v4.1-flash",
87
+ name: "DeepSeek V4.1 Flash",
88
+ limit: { context: 1_000_000 },
89
+ reasoning: true,
90
+ reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }],
91
+ },
92
+ },
93
+ },
94
+ });
95
+ // A non-picker provider is dropped; opencode-go is kept.
96
+ expect(Object.keys(catalog)).toEqual(["opencode-go"]);
97
+ expect(catalog["opencode-go"]?.models["deepseek-v4.1-flash"]).toMatchObject({
98
+ id: "deepseek-v4.1-flash",
99
+ limit: { context: 1_000_000 },
100
+ reasoning: true,
101
+ reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }],
102
+ });
103
+ });
77
104
  });
78
105
 
79
106
  describe("getModelsCatalog", () => {
@@ -85,6 +112,11 @@ describe("getModelsCatalog", () => {
85
112
  expect(Object.keys(result.providers.openrouter?.models ?? {}).length).toBeGreaterThan(0);
86
113
  expect(Object.keys(result.providers.anthropic?.models ?? {}).length).toBeGreaterThan(0);
87
114
  expect(Object.keys(result.providers.opencode?.models ?? {}).length).toBeGreaterThan(0);
115
+ // OpenCode Go is a catalog provider too (its sibling `opencode` is Zen).
116
+ expect(result.providers["opencode-go"]?.name).toBe("OpenCode Go");
117
+ expect(result.providers["opencode-go"]?.models["deepseek-v4-flash"]?.limit?.context).toBe(
118
+ 1_000_000,
119
+ );
88
120
  expect(result.providers.nan?.models["glm5.3-flash"]).toBeDefined();
89
121
  expect(Object.keys(result.providers.helmcode?.models ?? {}).sort()).toEqual([
90
122
  "deepseek-v4-flash",
@@ -159,3 +191,21 @@ describe("nan and helmcode sections", () => {
159
191
  expect(getContextWindowSize("nan/qwen3.6")).toBe(262_144);
160
192
  });
161
193
  });
194
+
195
+ describe("opencode-go section", () => {
196
+ test("the snapshot gives opencode-go ids their context window, not the 200k fallback", () => {
197
+ expect(getContextWindowSize("opencode-go/deepseek-v4-flash")).toBe(1_000_000);
198
+ });
199
+
200
+ test("opencode and pi keep the model's own max for an opencode-go id", () => {
201
+ const catalog = getModelsCatalog().providers;
202
+ // `deepseek-v4-flash` declares low|high|max and both harnesses pass it through.
203
+ for (const harness of ["opencode", "pi"] as const) {
204
+ expect(reasoningLevelsForModel(harness, "opencode-go/deepseek-v4-flash", catalog)).toEqual([
205
+ "low",
206
+ "high",
207
+ "max",
208
+ ]);
209
+ }
210
+ });
211
+ });
@@ -184,6 +184,27 @@ describe("onboarding state", () => {
184
184
  expect(body.state.version).toBe(1);
185
185
  });
186
186
 
187
+ test("a stored org name completes the name step, the old default does not", async () => {
188
+ const nameStep = async () =>
189
+ ((await (await request("/api/onboarding")).json()) as { state: OnboardingState }).state.steps
190
+ .name;
191
+ const previous = process.env.SWARM_ORG_NAME;
192
+ try {
193
+ delete process.env.SWARM_ORG_NAME;
194
+ expect((await nameStep()).status).toBe("todo");
195
+ process.env.SWARM_ORG_NAME = " Your Swarm ";
196
+ expect((await nameStep()).status).toBe("todo");
197
+ process.env.SWARM_ORG_NAME = "Acme";
198
+ expect(await nameStep()).toMatchObject({ status: "done", method: "custom_name" });
199
+ // Clearing the name later never undoes a done step.
200
+ delete process.env.SWARM_ORG_NAME;
201
+ expect((await nameStep()).status).toBe("done");
202
+ } finally {
203
+ if (previous === undefined) delete process.env.SWARM_ORG_NAME;
204
+ else process.env.SWARM_ORG_NAME = previous;
205
+ }
206
+ });
207
+
187
208
  test("concurrent first GETs create state and started telemetry once", async () => {
188
209
  const responses = await Promise.all(
189
210
  Array.from({ length: 8 }, () => request("/api/onboarding")),
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { validateConfigValue } from "../be/swarm-config-guard";
3
3
  import { buildContext } from "../telemetry-context";
4
+ import {
5
+ COMPANY_ACQUISITION_SOURCES,
6
+ COMPANY_TEAM_SIZES,
7
+ COMPANY_USE_CASES,
8
+ } from "../utils/company-profile";
4
9
  import {
5
10
  isFilledOrgLogoUrl,
6
11
  normalizeOrgWebsite,
@@ -75,6 +80,48 @@ describe("websiteSuggestion", () => {
75
80
  expect(websiteSuggestion("gmail.com")).toBeNull();
76
81
  expect(websiteSuggestion(null)).toBeNull();
77
82
  });
83
+
84
+ test("never suggests a regional or single-domain free-mail provider", () => {
85
+ for (const domain of [
86
+ "hotmail.es",
87
+ "yahoo.co.uk",
88
+ "outlook.fr",
89
+ "web.de",
90
+ "gmx.net",
91
+ "pm.me",
92
+ "mail.ru",
93
+ "live.co.uk",
94
+ "live.nl",
95
+ "yandex.com.tr",
96
+ "yandex.kz",
97
+ "icloud.com",
98
+ "me.com",
99
+ "proton.me",
100
+ "protonmail.ch",
101
+ "aol.de",
102
+ "qq.com",
103
+ "163.com",
104
+ "naver.com",
105
+ "Hotmail.CO.UK",
106
+ "mail.yahoo.com",
107
+ ]) {
108
+ expect(websiteSuggestion(domain)).toBeNull();
109
+ }
110
+ });
111
+
112
+ test("keeps company domains that only look like a provider", () => {
113
+ for (const domain of [
114
+ "acme.co.uk",
115
+ "acme.com.br",
116
+ "mailchimp.com",
117
+ "live.acme.com",
118
+ "yahoo-labs.dev",
119
+ "proton.ai",
120
+ "web.dev",
121
+ ]) {
122
+ expect(websiteSuggestion(domain)).toBe(domain);
123
+ }
124
+ });
78
125
  });
79
126
 
80
127
  describe("SWARM_ORG_WEBSITE config guard", () => {
@@ -95,6 +142,21 @@ describe("SWARM_ORG_WEBSITE config guard", () => {
95
142
  });
96
143
  });
97
144
 
145
+ describe("company profile config guards", () => {
146
+ for (const [key, values] of [
147
+ ["SWARM_ORG_TEAM_SIZE", COMPANY_TEAM_SIZES],
148
+ ["SWARM_ORG_USE_CASE", COMPANY_USE_CASES],
149
+ ["SWARM_ORG_ACQUISITION_SOURCE", COMPANY_ACQUISITION_SOURCES],
150
+ ] as const) {
151
+ test(`${key} accepts its enum or empty, and rejects other values`, () => {
152
+ for (const value of ["", ...values]) expect(validateConfigValue(key, value)).toBeNull();
153
+ for (const value of ["unknown", "OTHER", "free text", null, 1, true]) {
154
+ expect(validateConfigValue(key, value)).toContain(key);
155
+ }
156
+ });
157
+ }
158
+ });
159
+
98
160
  describe("org_website in the telemetry context", () => {
99
161
  const input = {
100
162
  orgId: "org_0123456789abcdef",
@@ -17,6 +17,7 @@ import {
17
17
  checkPiMonoCredentials,
18
18
  createPiRuntimeAuth,
19
19
  extractPiAssistantText,
20
+ listPiOpencodeGoModelIds,
20
21
  PiMonoAdapter,
21
22
  resolveModel,
22
23
  type SessionModelsJson,
@@ -457,6 +458,18 @@ describe("Model name mapping", () => {
457
458
  });
458
459
  });
459
460
 
461
+ describe("listPiOpencodeGoModelIds", () => {
462
+ test("every reported opencode-go id resolves, so a picked id never falls back", () => {
463
+ const ids = listPiOpencodeGoModelIds();
464
+ expect(ids).toContain("deepseek-v4.1-flash");
465
+ for (const id of ids) {
466
+ expect(resolveModel(`opencode-go/${id}`, {})?.id).toBe(id);
467
+ }
468
+ expect(ids).not.toContain("deepseek-v4-flash");
469
+ expect(resolveModel("opencode-go/deepseek-v4-flash", {})).toBeUndefined();
470
+ });
471
+ });
472
+
460
473
  describe("resolveModel — OpenRouter reroute for anthropic shortnames", () => {
461
474
  // Regression coverage for task 37a4a87a: workers spawned with
462
475
  // `provider: pi` + `OPENROUTER_API_KEY` (no ANTHROPIC_API_KEY) and a task
@@ -546,6 +559,16 @@ describe("createPiRuntimeAuth", () => {
546
559
  });
547
560
  });
548
561
 
562
+ test("passes OPENCODE_API_KEY to pi opencode-go runtime auth", async () => {
563
+ const modelRuntime = await createPiRuntimeAuth({
564
+ OPENCODE_API_KEY: "example-opencode-runtime",
565
+ });
566
+
567
+ await expect(modelRuntime.getAuth("opencode-go")).resolves.toMatchObject({
568
+ auth: { apiKey: "example-opencode-runtime" },
569
+ });
570
+ });
571
+
549
572
  test("passes GEMINI_API_KEY to pi google runtime auth", async () => {
550
573
  const modelRuntime = await createPiRuntimeAuth({
551
574
  GEMINI_API_KEY: "example-gemini-runtime",
@@ -339,14 +339,18 @@ describe("parseGhPrCommands", () => {
339
339
  );
340
340
  expect(calls).toEqual([
341
341
  {
342
+ action: "create",
342
343
  cwd: "/tmp/wt",
343
344
  repos: ["o/r"],
345
+ resolvedRepos: ["o/r"],
344
346
  alsoCheckout: false,
345
347
  unknownTarget: false,
346
348
  texts: ["t"],
349
+ bodyTexts: [],
347
350
  bodyFiles: ["body.md"],
348
351
  unresolvedBodyFiles: [],
349
352
  opaqueBody: false,
353
+ externalBody: false,
350
354
  },
351
355
  ]);
352
356
  });