@desplega.ai/agent-swarm 2.7.4 → 2.7.5

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 (253) hide show
  1. package/README.md +1 -1
  2. package/dist/acp-adapter-g3ads55s.js +15 -0
  3. package/dist/{actions-gfrr9vb7.js → actions-zwfykqqk.js} +18 -18
  4. package/dist/{amp-adapter-0s58gm46.js → amp-adapter-z3s0ssdt.js} +3 -3
  5. package/dist/{app-stvq692g.js → app-vmp4fgep.js} +3 -3
  6. package/dist/{assistant-5s37fr9n.js → assistant-vwde653f.js} +28 -21
  7. package/dist/{boot-reembed-yzy1841n.js → boot-reembed-375t7tbk.js} +1 -1
  8. package/dist/{boot-reembed-cv82gz7y.js → boot-reembed-gq9rcpnj.js} +1 -1
  9. package/dist/{boot-scrub-sweep-8k4q95ap.js → boot-scrub-sweep-nd938kyf.js} +4 -4
  10. package/dist/{boot-seeds-ftt9htah.js → boot-seeds-aykx51v3.js} +4 -4
  11. package/dist/{boxd-515v1t8z.js → boxd-k1j26g3h.js} +1 -1
  12. package/dist/{claude-adapter-t00wrzeh.js → claude-adapter-8j4hm099.js} +7 -7
  13. package/dist/{claude-managed-adapter-g6vmjccb.js → claude-managed-adapter-dvs2xw5n.js} +2 -2
  14. package/dist/{claude-managed-setup-afpa82f7.js → claude-managed-setup-0wyynpn1.js} +3 -3
  15. package/dist/{claude-sdk-session-vj0c2azx.js → claude-sdk-session-q6wj5hmy.js} +9 -9
  16. package/dist/{cli-wndv9p7g.js → cli-0731hvbw.js} +6 -6
  17. package/dist/{cli-d2pzqej1.js → cli-0bxqnvq9.js} +719 -7
  18. package/dist/{cli-jkt52mgt.js → cli-0gey5fzy.js} +2 -2
  19. package/dist/{cli-1yekbyqm.js → cli-1338796d.js} +205 -48
  20. package/dist/{cli-1s1dqn1j.js → cli-1ayk1eas.js} +171 -123
  21. package/dist/{cli-a4btqqft.js → cli-1b7btd38.js} +1 -1
  22. package/dist/{cli-a2jw6ee5.js → cli-1p89xny0.js} +1 -1
  23. package/dist/{cli-v726afdm.js → cli-2pnswgh8.js} +1 -1
  24. package/dist/{cli-kvtc7aez.js → cli-3h4nhjn8.js} +42 -14
  25. package/dist/{cli-655xm9ms.js → cli-583frbyr.js} +1 -1
  26. package/dist/{cli-4r12t6w6.js → cli-5c4hjzne.js} +101 -4
  27. package/dist/{cli-az0bpf8p.js → cli-5pag6rmn.js} +3 -3
  28. package/dist/{cli-36g23z21.js → cli-6s7vdcta.js} +4 -4
  29. package/dist/{cli-rtk9g7av.js → cli-73jzwksr.js} +6 -2
  30. package/dist/{cli-jg0nfda6.js → cli-75gkpjvw.js} +52 -21
  31. package/dist/{cli-a81qevyg.js → cli-7a42nfy0.js} +20 -36
  32. package/dist/{cli-0sbkss7h.js → cli-7wcyk2s5.js} +2 -2
  33. package/dist/{cli-41z8xwj0.js → cli-7wspm5gm.js} +14 -4
  34. package/dist/{cli-byqfbnjw.js → cli-82ae98pz.js} +9 -9
  35. package/dist/{cli-bq2pcphn.js → cli-8hs41zy2.js} +1 -1
  36. package/dist/{cli-vqgnyh76.js → cli-98rw5dt5.js} +15 -3
  37. package/dist/{cli-9cctx1tn.js → cli-a22h291q.js} +1 -1
  38. package/dist/{cli-j4kt1mpb.js → cli-a47xy8eq.js} +3 -3
  39. package/dist/{cli-ycm6180s.js → cli-a7v854rf.js} +5 -3
  40. package/dist/{cli-98pke4vk.js → cli-ae7mrdf9.js} +2 -2
  41. package/dist/{cli-ahd90bwt.js → cli-afrrppsw.js} +6 -6
  42. package/dist/{cli-8yy525ad.js → cli-bhjt02p8.js} +2 -2
  43. package/dist/{cli-v1yw3wha.js → cli-bnz7a7qf.js} +4 -4
  44. package/dist/{cli-j4kw3rsx.js → cli-cm5yqn35.js} +18 -0
  45. package/dist/{cli-h55y2bjc.js → cli-dhar9qay.js} +2 -2
  46. package/dist/{cli-5gr1g711.js → cli-fqzx7gwa.js} +2 -2
  47. package/dist/{cli-tzrpv5sc.js → cli-ftyxyspd.js} +1 -1
  48. package/dist/cli-fw2w0kva.js +47 -0
  49. package/dist/{cli-2dfbv8qr.js → cli-fy6x31ps.js} +1 -0
  50. package/dist/{cli-mvckssyq.js → cli-g61qxmge.js} +5 -5
  51. package/dist/{cli-aq58fk5m.js → cli-g83s9ewh.js} +7 -1
  52. package/dist/{cli-224epwwf.js → cli-hn9hxmz9.js} +3 -3
  53. package/dist/{cli-5hjced2v.js → cli-j3jvnpe8.js} +6 -6
  54. package/dist/{cli-dae4sbgy.js → cli-jm2vfpfd.js} +2 -2
  55. package/dist/{cli-zn15c46z.js → cli-mjzynrse.js} +1 -1
  56. package/dist/{cli-a1fhj4n9.js → cli-mmwm7zbf.js} +2 -2
  57. package/dist/{cli-cq1pf336.js → cli-mry0qzym.js} +5 -5
  58. package/dist/{cli-bwty35gx.js → cli-mthwvjre.js} +1 -1
  59. package/dist/{cli-s0exq74r.js → cli-nmbmy64z.js} +3 -3
  60. package/dist/{cli-wcmnan2g.js → cli-nn4d8dne.js} +5 -5
  61. package/dist/{cli-jg4p5t36.js → cli-nzxr556s.js} +121 -51
  62. package/dist/{cli-bcz074zx.js → cli-p599f1qm.js} +1 -1
  63. package/dist/{cli-k76tgq03.js → cli-pywf3eh5.js} +2 -2
  64. package/dist/{cli-wk9md5px.js → cli-qxnf8z2z.js} +2 -2
  65. package/dist/{cli-je8yeyc5.js → cli-rhw40jgb.js} +5 -5
  66. package/dist/{cli-avgcz4ra.js → cli-rm6hqj3z.js} +406 -35
  67. package/dist/{cli-0fgr1w9b.js → cli-rpm2sgv0.js} +2 -2
  68. package/dist/{cli-t282jgab.js → cli-rqkxrmgw.js} +12 -12
  69. package/dist/{cli-2qpvmmkw.js → cli-s0qnd4wv.js} +1 -1
  70. package/dist/{cli-pwyevs29.js → cli-spknvxbq.js} +22 -21
  71. package/dist/{cli-9y354g3j.js → cli-srye3vdt.js} +1 -1
  72. package/dist/{cli-pe09p6dv.js → cli-tqgg40ek.js} +1 -1
  73. package/dist/{cli-f255yaqn.js → cli-vbyfwh92.js} +3 -3
  74. package/dist/{cli-gcbdjt47.js → cli-vfym7wmz.js} +6 -6
  75. package/dist/{cli-edmm9d9y.js → cli-vqypxzh4.js} +7 -7
  76. package/dist/{cli-1gm8jcyr.js → cli-wvaawacd.js} +13 -13
  77. package/dist/{cli-eny54typ.js → cli-wx3rhnm0.js} +1 -1
  78. package/dist/{cli-b1afgtj3.js → cli-xbkdm710.js} +3 -3
  79. package/dist/{cli-mc97xy2s.js → cli-xtjhfmpc.js} +16 -4
  80. package/dist/{cli-eqx6k993.js → cli-y24sc29k.js} +25 -13
  81. package/dist/{cli-ktmvb20s.js → cli-z6jkx23q.js} +12 -12
  82. package/dist/{cli-7n5sbw0z.js → cli-z8eh3ex8.js} +5 -5
  83. package/dist/cli-zh3fp8v2.js +151 -0
  84. package/dist/{cli-13jvmew3.js → cli-zppwg9p9.js} +7 -7
  85. package/dist/cli.js +18 -18
  86. package/dist/{codex-adapter-g0gctw29.js → codex-adapter-q6k3gnyg.js} +5 -5
  87. package/dist/{codex-hook-vrzz2xhr.js → codex-hook-9efm2x1m.js} +1 -1
  88. package/dist/{codex-login-0gg46ygn.js → codex-login-y0pmff1c.js} +1 -1
  89. package/dist/{codex-session-runner-dr2cxe1c.js → codex-session-runner-3mwmk3d9.js} +6 -6
  90. package/dist/{commands-1p07cvff.js → commands-evrenjn9.js} +2 -2
  91. package/dist/{controller-z0s7sv6r.js → controller-xjp8vswh.js} +4 -4
  92. package/dist/{controllers-connect-x9kvvhyk.js → controllers-connect-pp5jnxvc.js} +20 -20
  93. package/dist/{cursor-adapter-m5b98pvk.js → cursor-adapter-yf47w4t0.js} +2 -2
  94. package/dist/{db-rsh04kh3.js → db-rgfsh91f.js} +2 -2
  95. package/dist/{db-capk2raj.js → db-zxpw6aht.js} +3 -3
  96. package/dist/{devin-adapter-yxm87fe9.js → devin-adapter-r9phbnc9.js} +1 -1
  97. package/dist/{dispatcher-304q6fwa.js → dispatcher-smasgws7.js} +17 -17
  98. package/dist/{docker-7rxt0rry.js → docker-rrjhaqt9.js} +2 -2
  99. package/dist/{dsh-adapter-btewem0y.js → dsh-adapter-ftyy8dnp.js} +2 -2
  100. package/dist/{e2b-renc0k8q.js → e2b-qcaj675y.js} +2 -2
  101. package/dist/{e2b-7fc9b9bd.js → e2b-w1p0bnpm.js} +2 -2
  102. package/dist/{easter-egg-3d-fm1p3n4d.js → easter-egg-3d-yyts7p16.js} +1 -1
  103. package/dist/{embedding-space-s68v49ct.js → embedding-space-1z0mbg95.js} +6 -6
  104. package/dist/{embeddings-4ms3604w.js → embeddings-ygeg7tm7.js} +1 -1
  105. package/dist/{event-bus-dfjx2qdh.js → event-bus-g0ka3pbw.js} +1 -1
  106. package/dist/{execute-mcdgccjs.js → execute-14qhqp4d.js} +1 -1
  107. package/dist/{external-channel-9dxwfztn.js → external-channel-ttgrs3sx.js} +2 -2
  108. package/dist/fx-adapter-gt6pncj2.js +18 -0
  109. package/dist/{grok-adapter-05ecv4h0.js → grok-adapter-sm4m5k4g.js} +8 -8
  110. package/dist/handlers-vwmba1m2.js +31 -0
  111. package/dist/{heartbeat-3n94heh3.js → heartbeat-qahz4p62.js} +20 -20
  112. package/dist/{hook-zxwje86c.js → hook-4fd5wq5w.js} +4 -4
  113. package/dist/{hook-aadrn69x.js → hook-kj3d398m.js} +6 -6
  114. package/dist/{http-xzxk9mst.js → http-1ted0j1k.js} +100 -64
  115. package/dist/index-5r71rf98.js +32 -0
  116. package/dist/{index-d34f6r87.js → index-6rq3r5zb.js} +1 -1
  117. package/dist/{index-9j4zwcht.js → index-7v51awbn.js} +158 -672
  118. package/dist/index-anmwxs87.js +29 -0
  119. package/dist/{index-qeb82z37.js → index-q4b917zs.js} +1 -1
  120. package/dist/index-spp0ra5c.js +36 -0
  121. package/dist/{keepalive-w4aakbyk.js → keepalive-6cf311t5.js} +4 -4
  122. package/dist/lead-fjy418cw.js +69 -0
  123. package/dist/{maintenance-hbptpk6p.js → maintenance-a4bm1bmt.js} +1 -1
  124. package/dist/{oauth-refresh-sweep-217semrv.js → oauth-refresh-sweep-3h0dfw1k.js} +3 -3
  125. package/dist/{onboard-vz2s5df3.js → onboard-myn5hraa.js} +3 -3
  126. package/dist/{opencode-adapter-gtfd7d7v.js → opencode-adapter-p2nkb5am.js} +5 -5
  127. package/dist/{otel-impl-aefave37.js → otel-impl-a1a0ef6b.js} +2 -2
  128. package/dist/{pi-mono-adapter-f8h5yseh.js → pi-mono-adapter-rzmpb72a.js} +117 -23
  129. package/dist/{pricing-refresh-m1yhh9ya.js → pricing-refresh-83drrnxj.js} +3 -3
  130. package/dist/{rbac-roles-svj72cdf.js → rbac-roles-0fb6e7pj.js} +2 -2
  131. package/dist/{rbac-roles-rrw8twdz.js → rbac-roles-s8y6ecb5.js} +1 -1
  132. package/dist/{render-v2-amwbnfxh.js → render-v2-twmbgrz4.js} +3 -3
  133. package/dist/{resume-9cd6wf6z.js → resume-zfqc36xj.js} +7 -7
  134. package/dist/{runtime-fp4vrx7m.js → runtime-qwggdpqr.js} +1 -1
  135. package/dist/{session-access-21t6yjyg.js → session-access-sc9pn9da.js} +4 -4
  136. package/dist/{session-source-backfill-pxbzcsc6.js → session-source-backfill-qh07xr8g.js} +3 -3
  137. package/dist/{setup-4592tqz4.js → setup-4fr0m7a1.js} +1 -1
  138. package/dist/{virtual-modules-gzfnpmnz.js → virtual-modules-fh4ha15b.js} +2 -2
  139. package/dist/{work-objects-t9tcf174.js → work-objects-0wr8zxct.js} +20 -20
  140. package/dist/worker-0c5tyzqe.js +69 -0
  141. package/dist/{worker-follow-up-q32jh4yk.js → worker-follow-up-ktyyazyp.js} +17 -17
  142. package/dist/{x-b6wxntdt.js → x-vmmye1kb.js} +2 -2
  143. package/openapi.json +97 -32
  144. package/package.json +1 -1
  145. package/src/be/db/session-summaries.ts +8 -2
  146. package/src/be/db/session-tree.ts +174 -61
  147. package/src/be/db/session-visibility.ts +5 -4
  148. package/src/be/db.ts +8 -4
  149. package/src/be/migrations/259_session_summaries_open_tasks.sql +111 -0
  150. package/src/be/model-validation.ts +5 -1
  151. package/src/be/pricing-normalize.ts +7 -2
  152. package/src/be/scripts/typecheck.ts +1 -0
  153. package/src/be/seed/schedules-seeder.ts +135 -9
  154. package/src/be/seed/workflows-seeder.ts +65 -1
  155. package/src/be/seed-pricing.ts +23 -0
  156. package/src/be/steering.ts +50 -3
  157. package/src/be/swarm-config-guard.ts +1 -0
  158. package/src/commands/provider-credentials.ts +29 -2
  159. package/src/commands/runner.ts +86 -5
  160. package/src/extensions/contract-types.generated.ts +1 -1
  161. package/src/extensions/contract.ts +27 -1
  162. package/src/extensions/dispatcher.ts +4 -0
  163. package/src/http/agents.ts +1 -0
  164. package/src/http/mcp-bridge.ts +4 -0
  165. package/src/integrations/chat-sdk/inbound.ts +15 -5
  166. package/src/integrations/chat-sdk/steering.ts +3 -1
  167. package/src/kv-reserved-namespaces.ts +2 -0
  168. package/src/prompts/base-prompt.ts +4 -1
  169. package/src/prompts/session-templates.ts +26 -0
  170. package/src/providers/acp-adapter.ts +190 -26
  171. package/src/providers/acp-targets.ts +306 -6
  172. package/src/providers/amp-adapter.ts +1 -1
  173. package/src/providers/codex-adapter.ts +9 -44
  174. package/src/providers/codex-oauth/flow.ts +24 -3
  175. package/src/providers/codex-oauth/storage.ts +56 -17
  176. package/src/providers/codex-oauth/subscription.ts +67 -0
  177. package/src/providers/fx-adapter.ts +211 -0
  178. package/src/providers/index.ts +5 -1
  179. package/src/providers/mcp-loopback-proxy.ts +94 -0
  180. package/src/providers/pi-mono-adapter.ts +98 -13
  181. package/src/providers/pricing-sources.md +8 -0
  182. package/src/providers/reasoning-effort.ts +17 -1
  183. package/src/providers/types.ts +2 -1
  184. package/src/scheduler/scheduler.ts +36 -3
  185. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  186. package/src/scripts-runtime/types/stdlib.d.ts +5 -0
  187. package/src/scripts-runtime/types/swarm-extension.d.ts +33 -0
  188. package/src/scripts-runtime/types/swarm-sdk.d.ts +5 -0
  189. package/src/server.ts +7 -0
  190. package/src/slack/assistant.ts +17 -1
  191. package/src/slack/handlers.ts +18 -5
  192. package/src/slack/left-threads.ts +65 -0
  193. package/src/slack/steering.ts +3 -0
  194. package/src/slack/thread-buffer.ts +40 -16
  195. package/src/tests/acp-adapter.test.ts +79 -0
  196. package/src/tests/base-prompt.test.ts +12 -0
  197. package/src/tests/chat-sdk-inbound.test.ts +30 -0
  198. package/src/tests/chatgpt-subscription-model-groups.test.ts +62 -0
  199. package/src/tests/chatgpt-subscription-pi.test.ts +170 -0
  200. package/src/tests/chatgpt-subscription-session-bench.test.ts +198 -0
  201. package/src/tests/codex-oauth-credential-info.test.ts +40 -0
  202. package/src/tests/codex-oauth-refresh-egress.test.ts +194 -0
  203. package/src/tests/context-window.test.ts +7 -0
  204. package/src/tests/explicit-model-validation.test.ts +99 -0
  205. package/src/tests/extensions-run-context.test.ts +11 -0
  206. package/src/tests/extensions-steering.test.ts +353 -0
  207. package/src/tests/extensions-validate.test.ts +5 -0
  208. package/src/tests/fixtures/extensions/steering-policy/hooks.ts +39 -0
  209. package/src/tests/fixtures/extensions/steering-policy/manifest.json +7 -0
  210. package/src/tests/fixtures/fake-fx.ts +167 -0
  211. package/src/tests/fixtures/fx/acp-responses-recorded.json +157 -0
  212. package/src/tests/fixtures/fx/acp-updates-recorded.jsonl +15 -0
  213. package/src/tests/fx-adapter.test.ts +771 -0
  214. package/src/tests/grok-adapter.test.ts +36 -0
  215. package/src/tests/mcp-loopback-proxy.test.ts +193 -0
  216. package/src/tests/model-tier-resolution.test.ts +3 -3
  217. package/src/tests/pricing-refresh.test.ts +5 -5
  218. package/src/tests/prompt-template-session.test.ts +5 -3
  219. package/src/tests/provider-registration.test.ts +2 -1
  220. package/src/tests/query-audit-plans.test.ts +3 -2
  221. package/src/tests/script-connections.test.ts +2 -0
  222. package/src/tests/seed-model-profiles.test.ts +2 -2
  223. package/src/tests/seed-schedules.test.ts +138 -0
  224. package/src/tests/session-cost-recompute-unit.test.ts +39 -0
  225. package/src/tests/session-costs-golden.test.ts +26 -0
  226. package/src/tests/session-summaries-plans.test.ts +120 -0
  227. package/src/tests/session-summaries.test.ts +72 -12
  228. package/src/tests/sessions-tree.test.ts +11 -3
  229. package/src/tests/slack-leave-thread.test.ts +431 -0
  230. package/src/tests/tool-annotations.test.ts +2 -1
  231. package/src/tests/workflow-engine-v2.test.ts +42 -0
  232. package/src/tests/workflow-script-principal.test.ts +207 -7
  233. package/src/tools/slack-leave-thread.ts +104 -0
  234. package/src/tools/steer-task.ts +3 -2
  235. package/src/tools/tool-config.ts +2 -1
  236. package/src/types.ts +19 -0
  237. package/src/utils/codex-auth-failure.ts +30 -0
  238. package/src/utils/context-window.ts +4 -1
  239. package/src/utils/credentials.ts +7 -0
  240. package/src/utils/fx-models.ts +43 -0
  241. package/src/workflows/cooldown.ts +3 -2
  242. package/src/workflows/engine.ts +24 -9
  243. package/templates/schedules/dreaming-daily/config.json +1 -1
  244. package/templates/schedules/dreaming-daily/content.md +6 -14
  245. package/templates/skills/swarm-docs/content.md +1 -0
  246. package/templates/workflows/dreaming/content.md +5 -1
  247. package/dist/acp-adapter-96dc64m2.js +0 -15
  248. package/dist/handlers-jg7v74ty.js +0 -31
  249. package/dist/index-8c1zbz59.js +0 -29
  250. package/dist/index-8k8tmfxd.js +0 -32
  251. package/dist/index-hq617xdj.js +0 -36
  252. package/dist/lead-567kw7x3.js +0 -68
  253. package/dist/worker-fjdqra6q.js +0 -68
@@ -145,6 +145,8 @@ class FakeGrok {
145
145
  }
146
146
  async newSession(params) {
147
147
  capture.newSession = { meta: params._meta ?? null, mcpServers: params.mcpServers.map((s) => s.name) };
148
+ capture.newSessionKeys = Object.keys(params).sort();
149
+ capture.mcpUrls = params.mcpServers.map((s) => s.url);
148
150
  writeFileSync("capture.json", JSON.stringify(capture));
149
151
  if (${JSON.stringify(mode)} === "auth-required") throw RequestError.authRequired();
150
152
  return { sessionId: "grok-session-1" };
@@ -425,6 +427,40 @@ describe("GrokAdapter", () => {
425
427
  }
426
428
  });
427
429
 
430
+ test("the opt-in ACP hooks leave grok's session/new payload, MCP URL and steering unchanged", async () => {
431
+ const dir = makeTempDir();
432
+ const grok = writeFakeGrok(dir, "ok");
433
+ const { server, apiUrl } = await startTokenStub();
434
+ // Reachable but plain-HTTP and non-loopback: a target with requiresSecureMcpUrl would proxy it.
435
+ const publicStyleUrl = apiUrl.replace("127.0.0.1", "0.0.0.0");
436
+ try {
437
+ const config = baseConfig({
438
+ apiUrl: publicStyleUrl,
439
+ resumeSessionId: "ignored-without-a-resume-hook",
440
+ env: {
441
+ PATH: process.env.PATH ?? "",
442
+ HOME: makeTempDir(),
443
+ GROK_BINARY: grok,
444
+ XAI_API_KEY: "xai-test-key",
445
+ },
446
+ });
447
+ const adapter = new GrokAdapter();
448
+ const session = await adapter.createSession(config);
449
+ await session.waitForCompletion();
450
+ expect(session.deliverSteering).toBeUndefined();
451
+ expect(await adapter.canResume("grok-session-1")).toBe(false);
452
+ const capture = JSON.parse(await Bun.file(join(config.cwd, "capture.json")).text());
453
+ expect(capture.newSession).toEqual({
454
+ meta: { rules: "You are a swarm worker.", yoloMode: true },
455
+ mcpServers: ["swarm"],
456
+ });
457
+ expect(capture.newSessionKeys).toEqual(["_meta", "cwd", "mcpServers"]);
458
+ expect(capture.mcpUrls).toEqual([`${publicStyleUrl}/mcp`]);
459
+ } finally {
460
+ await new Promise<void>((resolve) => server.close(() => resolve()));
461
+ }
462
+ });
463
+
428
464
  test("runs an openrouter/ model as an OpenAI-compatible model on OPENROUTER_API_KEY", async () => {
429
465
  const dir = makeTempDir();
430
466
  // A BYOK route reports the endpoint's id and no ticks.
@@ -0,0 +1,193 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { secureMcpServers } from "../providers/acp-adapter";
3
+ import {
4
+ type McpLoopbackProxy,
5
+ needsLoopbackProxy,
6
+ startMcpLoopbackProxy,
7
+ } from "../providers/mcp-loopback-proxy";
8
+ import { getFreePort } from "./test-net";
9
+
10
+ type Upstream = ReturnType<typeof Bun.serve>;
11
+
12
+ const cleanups: Array<() => Promise<void> | void> = [];
13
+
14
+ afterEach(async () => {
15
+ for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
16
+ });
17
+
18
+ function track(proxy: McpLoopbackProxy): McpLoopbackProxy {
19
+ cleanups.push(() => proxy.stop());
20
+ return proxy;
21
+ }
22
+
23
+ /** A stand-in MCP endpoint: JSON echo, a slow SSE stream and a redirect. */
24
+ function startUpstream(): Upstream {
25
+ const server = Bun.serve({
26
+ hostname: "127.0.0.1",
27
+ port: 0,
28
+ idleTimeout: 0,
29
+ async fetch(req) {
30
+ const url = new URL(req.url);
31
+ if (url.pathname === "/prefix/mcp" && req.method === "POST") {
32
+ return Response.json({
33
+ path: url.pathname,
34
+ search: url.search,
35
+ method: req.method,
36
+ body: await req.json(),
37
+ authorization: req.headers.get("authorization"),
38
+ agentId: req.headers.get("x-agent-id"),
39
+ host: req.headers.get("host"),
40
+ });
41
+ }
42
+ if (url.pathname === "/prefix/sse") {
43
+ const encoder = new TextEncoder();
44
+ const stream = new ReadableStream<Uint8Array>({
45
+ async start(controller) {
46
+ for (const n of [1, 2, 3]) {
47
+ controller.enqueue(encoder.encode(`event: message\ndata: {"n":${n}}\n\n`));
48
+ await Bun.sleep(30);
49
+ }
50
+ controller.close();
51
+ },
52
+ });
53
+ return new Response(stream, { headers: { "Content-Type": "text/event-stream" } });
54
+ }
55
+ if (url.pathname === "/prefix/redirect") {
56
+ return new Response(null, { status: 302, headers: { Location: "https://example.test/" } });
57
+ }
58
+ return new Response("not found", { status: 404 });
59
+ },
60
+ });
61
+ cleanups.push(() => server.stop(true));
62
+ return server;
63
+ }
64
+
65
+ describe("needsLoopbackProxy", () => {
66
+ test("only plain HTTP to a non-loopback host needs the forwarder", () => {
67
+ expect(needsLoopbackProxy("http://api:3013/mcp")).toBe(true);
68
+ expect(needsLoopbackProxy("http://10.0.0.5/mcp")).toBe(true);
69
+ // fx accepts only these loopback names; 127.0.0.2 is rejected (fx 0.0.15).
70
+ expect(needsLoopbackProxy("http://127.0.0.2:3013/mcp")).toBe(true);
71
+ expect(needsLoopbackProxy("http://127.0.0.1:3013/mcp")).toBe(false);
72
+ expect(needsLoopbackProxy("http://localhost:3013/mcp")).toBe(false);
73
+ expect(needsLoopbackProxy("http://[::1]:3013/mcp")).toBe(false);
74
+ expect(needsLoopbackProxy("https://swarm.example/mcp")).toBe(false);
75
+ expect(needsLoopbackProxy("not a url")).toBe(false);
76
+ });
77
+ });
78
+
79
+ describe("startMcpLoopbackProxy", () => {
80
+ test("round-trips a JSON POST with its headers, under the upstream path prefix", async () => {
81
+ const upstream = startUpstream();
82
+ const proxy = track(await startMcpLoopbackProxy(`http://127.0.0.1:${upstream.port}/prefix/`));
83
+ expect(proxy.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
84
+
85
+ const response = await fetch(`${proxy.url}/mcp?session=1`, {
86
+ method: "POST",
87
+ headers: {
88
+ Authorization: "Bearer aseph_proxytest",
89
+ "X-Agent-ID": "agent-1",
90
+ "Content-Type": "application/json",
91
+ },
92
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
93
+ });
94
+ expect(response.status).toBe(200);
95
+ expect(await response.json()).toEqual({
96
+ path: "/prefix/mcp",
97
+ search: "?session=1",
98
+ method: "POST",
99
+ body: { jsonrpc: "2.0", id: 1, method: "tools/list" },
100
+ authorization: "Bearer aseph_proxytest",
101
+ agentId: "agent-1",
102
+ // The upstream sees its own host, not the forwarder's.
103
+ host: `127.0.0.1:${upstream.port}`,
104
+ });
105
+ });
106
+
107
+ test("streams SSE events in order as they arrive", async () => {
108
+ const upstream = startUpstream();
109
+ const proxy = track(await startMcpLoopbackProxy(`http://127.0.0.1:${upstream.port}/prefix`));
110
+ const response = await fetch(`${proxy.url}/sse`);
111
+ expect(response.headers.get("content-type")).toContain("text/event-stream");
112
+ const reader = response.body!.getReader();
113
+ const decoder = new TextDecoder();
114
+ let text = "";
115
+ let reads = 0;
116
+ while (true) {
117
+ const { done, value } = await reader.read();
118
+ if (done) break;
119
+ reads += 1;
120
+ text += decoder.decode(value, { stream: true });
121
+ }
122
+ expect([...text.matchAll(/"n":(\d)/g)].map((m) => m[1])).toEqual(["1", "2", "3"]);
123
+ // Delivered incrementally, not buffered into one body.
124
+ expect(reads).toBeGreaterThan(1);
125
+ });
126
+
127
+ test("passes a redirect through instead of following it", async () => {
128
+ const upstream = startUpstream();
129
+ const proxy = track(await startMcpLoopbackProxy(`http://127.0.0.1:${upstream.port}/prefix`));
130
+ const response = await fetch(`${proxy.url}/redirect`, { redirect: "manual" });
131
+ expect(response.status).toBe(302);
132
+ expect(response.headers.get("location")).toBe("https://example.test/");
133
+ });
134
+
135
+ test("answers 502 when the upstream is down", async () => {
136
+ const port = await getFreePort();
137
+ const proxy = track(await startMcpLoopbackProxy(`http://127.0.0.1:${port}`));
138
+ const response = await fetch(`${proxy.url}/mcp`, { method: "POST", body: "{}" });
139
+ expect(response.status).toBe(502);
140
+ expect(((await response.json()) as { error: string }).error).toContain(
141
+ "MCP loopback proxy: upstream failed",
142
+ );
143
+ });
144
+
145
+ test("stop() frees the port and is idempotent", async () => {
146
+ const upstream = startUpstream();
147
+ const proxy = await startMcpLoopbackProxy(`http://127.0.0.1:${upstream.port}/prefix`);
148
+ await proxy.stop();
149
+ await proxy.stop();
150
+ await expect(fetch(`${proxy.url}/sse`)).rejects.toThrow();
151
+ });
152
+ });
153
+
154
+ describe("secureMcpServers", () => {
155
+ test("forwards plain-HTTP non-loopback servers, one forwarder per origin, and drops SSE", async () => {
156
+ const proxies: McpLoopbackProxy[] = [];
157
+ cleanups.push(async () => {
158
+ for (const proxy of proxies) await proxy.stop();
159
+ });
160
+ const servers = await secureMcpServers(
161
+ [
162
+ { type: "http", name: "swarm", url: "http://api:3013/mcp", headers: [] },
163
+ { type: "http", name: "same-origin", url: "http://api:3013/other?x=1", headers: [] },
164
+ { type: "http", name: "secure", url: "https://mcp.example/mcp", headers: [] },
165
+ { type: "http", name: "local", url: "http://127.0.0.1:9/mcp", headers: [] },
166
+ { type: "sse", name: "legacy", url: "https://sse.example/sse", headers: [] },
167
+ { name: "stdio", command: "true", args: [], env: [] },
168
+ ],
169
+ false,
170
+ proxies,
171
+ );
172
+ expect(proxies).toHaveLength(1);
173
+ const forwarded = proxies[0]!.url;
174
+ expect(servers.map((server) => [server.name, "url" in server ? server.url : null])).toEqual([
175
+ ["swarm", `${forwarded}/mcp`],
176
+ ["same-origin", `${forwarded}/other?x=1`],
177
+ ["secure", "https://mcp.example/mcp"],
178
+ ["local", "http://127.0.0.1:9/mcp"],
179
+ ["stdio", null],
180
+ ]);
181
+ });
182
+
183
+ test("keeps SSE servers when the agent supports them", async () => {
184
+ const proxies: McpLoopbackProxy[] = [];
185
+ const servers = await secureMcpServers(
186
+ [{ type: "sse", name: "legacy", url: "https://sse.example/sse", headers: [] }],
187
+ true,
188
+ proxies,
189
+ );
190
+ expect(servers.map((server) => server.name)).toEqual(["legacy"]);
191
+ expect(proxies).toEqual([]);
192
+ });
193
+ });
@@ -674,8 +674,8 @@ describe("previewModelTiers", () => {
674
674
 
675
675
  // acp has no portable tier mapping, so it has nothing to preview.
676
676
  expect(rows.some((r) => r.provider === "acp")).toBe(false);
677
- expect(new Set(rows.map((r) => r.provider)).size).toBe(10);
678
- expect(rows).toHaveLength(40);
677
+ expect(new Set(rows.map((r) => r.provider)).size).toBe(11);
678
+ expect(rows).toHaveLength(44);
679
679
  });
680
680
 
681
681
  test("an alias that resolves to nothing reports the default it falls back to", async () => {
@@ -730,7 +730,7 @@ describe("GET /api/models-catalog/tiers", () => {
730
730
  expect(handled).toBe(true);
731
731
  expect(status).toBe(200);
732
732
  const body = JSON.parse(bodyStr) as { tiers: { provider: string; tier: string }[] };
733
- expect(body.tiers).toHaveLength(40);
733
+ expect(body.tiers).toHaveLength(44);
734
734
  expect(body.tiers.find((t) => t.provider === "claude" && t.tier === "smart")).toMatchObject({
735
735
  key: "MODEL_TIER_CLAUDE_SMART",
736
736
  source: "tier-config",
@@ -102,14 +102,14 @@ describe("models.dev runtime pricing refresh", () => {
102
102
  VALUES ('codex', 'gpt-refresh-test', 'input', 0, 1, 0, 0)`,
103
103
  );
104
104
 
105
- // codex, pi, cursor and amp each project the OpenAI rows: 4 providers x 2 token classes.
105
+ // codex, pi, cursor, amp and fx each project the OpenAI rows: 5 providers x 2 token classes.
106
106
  const first = await refreshPricingFromModelsDev({
107
107
  now: 1_000,
108
108
  fetchImpl: async () => responseFor(openAiCache(2, 8), '"etag-1"'),
109
109
  });
110
110
  expect(first.status).toBe("refreshed");
111
- expect(first.candidateRows).toBe(8);
112
- expect(first.inserted).toBe(8);
111
+ expect(first.candidateRows).toBe(10);
112
+ expect(first.inserted).toBe(10);
113
113
  expect(first.unchanged).toBe(0);
114
114
 
115
115
  const activeChanged = await getActivePricingRow("codex", "gpt-refresh-test", "input", 1_000);
@@ -121,7 +121,7 @@ describe("models.dev runtime pricing refresh", () => {
121
121
  fetchImpl: async () => responseFor(openAiCache(2, 8), '"etag-2"'),
122
122
  });
123
123
  expect(second.inserted).toBe(0);
124
- expect(second.unchanged).toBe(8);
124
+ expect(second.unchanged).toBe(10);
125
125
 
126
126
  const rows = await getDbClient().query<{ effective_from: number }>(
127
127
  `SELECT effective_from FROM pricing
@@ -278,7 +278,7 @@ describe("models.dev runtime pricing refresh", () => {
278
278
 
279
279
  const logs = await getLogsByEventType("pricing.refresh");
280
280
  expect(logs).toHaveLength(1);
281
- expect(logs[0]?.newValue).toContain("inserted=8");
281
+ expect(logs[0]?.newValue).toContain("inserted=10");
282
282
  expect(logs[0]?.metadata).toContain('"etag":"\\"etag-log\\""');
283
283
  });
284
284
  });
@@ -24,6 +24,7 @@ const SYSTEM_TEMPLATES = [
24
24
  "system.agent.compaction.goal_reminder",
25
25
  "system.agent.context.bound_note",
26
26
  "system.agent.cursor.first_message",
27
+ "system.agent.fx.system_prompt_file",
27
28
  "system.agent.lead",
28
29
  "system.agent.lead.docs",
29
30
  "system.agent.memory",
@@ -42,6 +43,7 @@ const SYSTEM_TEMPLATES = [
42
43
  "system.agent.tool_discovery.search",
43
44
  "system.agent.tool_discovery.amp",
44
45
  "system.agent.tool_discovery.codex",
46
+ "system.agent.tool_discovery.fx",
45
47
  "system.agent.tool_discovery.opencode",
46
48
  "system.agent.tool_discovery.direct",
47
49
  "system.agent.tools_skills",
@@ -121,11 +123,11 @@ describe("Session templates: registration", () => {
121
123
  expect(sessionSystemEventTypes("session")).toEqual([...SESSION_TEMPLATES].sort());
122
124
  });
123
125
 
124
- test("registers 35 system and session templates in total", () => {
126
+ test("registers 37 system and session templates in total", () => {
125
127
  const all = getAllTemplateDefinitions();
126
128
  const sessionSystem = all.filter((d) => d.category === "system" || d.category === "session");
127
- // 30 system blocks + 5 session composites.
128
- expect(sessionSystem.length).toBe(35);
129
+ // 32 system blocks + 5 session composites.
130
+ expect(sessionSystem.length).toBe(37);
129
131
  });
130
132
 
131
133
  test("drops the v1 blocks that prompt v2 deleted", () => {
@@ -235,6 +235,7 @@ const BOOTSTRAP: Record<ProviderName, { expect: string } | { exempt: string }> =
235
235
  amp: { expect: "Warning: amp provider has no credentials yet" },
236
236
  cursor: { expect: "Warning: cursor provider has no credentials yet" },
237
237
  grok: { expect: "Warning: grok provider has no credentials yet" },
238
+ fx: { expect: "Warning: fx provider has no credentials yet" },
238
239
  pi: { expect: "Warning: pi provider has no credentials yet" },
239
240
  opencode: { expect: "Warning: opencode provider has no credentials yet" },
240
241
  "claude-managed": { expect: "Warning: claude-managed provider missing:" },
@@ -284,7 +285,7 @@ function extractMarkedBlock(name: string): string {
284
285
  /** Resolvable no-op stubs for every binary the verify chain may look up. */
285
286
  function makeBinaryStubDir(): string {
286
287
  const dir = mkdtempSync(join(tmpdir(), "provider-registration-bin-"));
287
- for (const name of ["claude", "codex", "opencode", "dsh", "amp", "grok"]) {
288
+ for (const name of ["claude", "codex", "opencode", "dsh", "amp", "grok", "fx"]) {
288
289
  const file = join(dir, name);
289
290
  writeFileSync(file, "#!/bin/sh\nexit 0\n");
290
291
  chmodSync(file, 0o755);
@@ -152,8 +152,9 @@ const CASES: Array<{
152
152
  name: "countSessions by source",
153
153
  match: /COUNT/,
154
154
  run: () => countSessions({ source: ["slack"] }),
155
- uses: ["COVERING INDEX idx_agent_tasks_parentTaskId_source (parentTaskId=? AND source=?)"],
156
- revert: { dropIndex: "idx_agent_tasks_parentTaskId_source" },
155
+ // Counts read session_summaries since migration 259.
156
+ uses: ["COVERING INDEX idx_session_summaries_source (source=?)"],
157
+ revert: { dropIndex: "idx_session_summaries_source" },
157
158
  },
158
159
  {
159
160
  // Few hits: the page reads the agent_tasks_fts candidates by rowid. The
@@ -665,6 +665,8 @@ describe("script connections", () => {
665
665
  markMigrationApplied(database, "254_query_audit_indexes.sql");
666
666
  // 256 alters agents, which this migration-112-only fixture does not create.
667
667
  markMigrationApplied(database, "256_agent_slack_icon.sql");
668
+ // 259 alters session_summaries, which 253 (skipped above) creates.
669
+ markMigrationApplied(database, "259_session_summaries_open_tasks.sql");
668
670
 
669
671
  const id = crypto.randomUUID();
670
672
  const now = new Date().toISOString();
@@ -62,9 +62,9 @@ beforeEach(async () => {
62
62
 
63
63
  describe("tier seeds", () => {
64
64
  test("one profile per harness and tier, copied from the tier map; none for acp", async () => {
65
- expect(await seedModelProfiles()).toBe(40);
65
+ expect(await seedModelProfiles()).toBe(44);
66
66
  const profiles = await listModelProfiles();
67
- expect(profiles).toHaveLength(40);
67
+ expect(profiles).toHaveLength(44);
68
68
  expect(profiles.some((p) => p.harness === "acp")).toBe(false);
69
69
  for (const profile of profiles) {
70
70
  const tier = profile.seededFrom?.replace("tier:", "") as (typeof MODEL_TIERS)[number];
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import {
4
4
  closeDb,
5
+ createWorkflow,
5
6
  getScheduledTaskByName,
6
7
  getScheduledTasks,
7
8
  initDb,
@@ -13,6 +14,7 @@ import {
13
14
  loadSeedSchedules,
14
15
  type ScheduleTemplateSource,
15
16
  } from "../be/seed/schedules-seeder";
17
+ import { createWorkflowsSeeder } from "../be/seed/workflows-seeder";
16
18
 
17
19
  const TEST_DB_PATH = "./test-seed-schedules.sqlite";
18
20
 
@@ -72,6 +74,31 @@ function zeroConfigScheduleSource(opts?: {
72
74
  } satisfies ScheduleTemplateSource;
73
75
  }
74
76
 
77
+ /** A schedule template with an explicit target block and an optional task prompt. */
78
+ function targetScheduleSource(target: Record<string, unknown>, prompt?: string) {
79
+ return {
80
+ config: JSON.stringify({
81
+ name: "test-target-schedule",
82
+ title: "Test target schedule",
83
+ description: "Seeded schedule with a target.",
84
+ placeholders: [],
85
+ requires: [],
86
+ runAllSeedersCandidate: true,
87
+ autoEnableCandidate: true,
88
+ }),
89
+ content: `# Test\n\n## Schedule\n\n\`\`\`json\n${JSON.stringify({
90
+ cron: "0 9 * * *",
91
+ timezone: "UTC",
92
+ enabled: true,
93
+ ...target,
94
+ })}\n\`\`\`\n${prompt === undefined ? "" : `\n## Scheduled Task\n\n${prompt}`}`,
95
+ } satisfies ScheduleTemplateSource;
96
+ }
97
+
98
+ async function makeWorkflow(name: string) {
99
+ return createWorkflow({ name, definition: { nodes: [] } });
100
+ }
101
+
75
102
  const ORIGINAL_SEED_AUTOMATIONS_ENABLED = process.env.SEED_AUTOMATIONS_ENABLED;
76
103
 
77
104
  beforeEach(async () => {
@@ -133,6 +160,8 @@ describe("schedules seeder", () => {
133
160
 
134
161
  test("SEED_AUTOMATIONS_ENABLED=false still seeds all schedules disabled", async () => {
135
162
  process.env.SEED_AUTOMATIONS_ENABLED = "false";
163
+ // dreaming-daily targets the seeded dreaming workflow.
164
+ await runSeeder(createWorkflowsSeeder(), { quiet: true });
136
165
  const result = await runSeeder(createSchedulesSeeder(), { quiet: true });
137
166
  expect(result).toMatchObject({ created: 12, failed: [] });
138
167
  const schedules = await getScheduledTasks();
@@ -141,6 +170,8 @@ describe("schedules seeder", () => {
141
170
  });
142
171
 
143
172
  test("seeds a disabled schedule whose timezone still needs setup", async () => {
173
+ // dreaming-daily targets the seeded dreaming workflow.
174
+ await runSeeder(createWorkflowsSeeder(), { quiet: true });
144
175
  const result = await runSeeder(createSchedulesSeeder(), { quiet: true });
145
176
  expect(result).toMatchObject({ created: 12, failed: [] });
146
177
  expect(await getScheduledTaskByName("weekly-dependabot-triage")).toMatchObject({
@@ -245,6 +276,113 @@ describe("schedules seeder", () => {
245
276
  );
246
277
  });
247
278
 
279
+ test("dreaming-daily is a workflow schedule with no task prompt", async () => {
280
+ await runSeeder(createWorkflowsSeeder(), { quiet: true });
281
+ const dreaming = loadSeedSchedules().find((schedule) => schedule.name === "dreaming-daily");
282
+ expect(dreaming).toMatchObject({
283
+ targetType: "workflow",
284
+ workflow: "dreaming",
285
+ taskTemplate: "",
286
+ });
287
+ // Agent-task templates carry no target keys, so their seed hash is unchanged.
288
+ const digest = loadSeedSchedules().find((schedule) => schedule.name === "daily-blocker-digest");
289
+ expect(digest && "targetType" in digest).toBe(false);
290
+ });
291
+
292
+ test("seeds a workflow target by name and re-runs as a no-op", async () => {
293
+ const workflow = await makeWorkflow("test-target-workflow");
294
+ const seeder = createSchedulesSeeder([
295
+ targetScheduleSource({ targetType: "workflow", workflow: "test-target-workflow" }),
296
+ ]);
297
+ expect(await runSeeder(seeder, { quiet: true })).toMatchObject({ created: 1, failed: [] });
298
+ const row = await getScheduledTaskByName("test-target-schedule");
299
+ expect(row).toMatchObject({ targetType: "workflow", workflowId: workflow.id });
300
+ expect(row?.taskTemplate).toBeUndefined();
301
+ expect(await runSeeder(seeder, { quiet: true })).toMatchObject({
302
+ skippedUnchanged: 1,
303
+ updated: 0,
304
+ });
305
+ });
306
+
307
+ test("seeds a script target and fails on a missing script", async () => {
308
+ const seeder = createSchedulesSeeder([
309
+ targetScheduleSource({ targetType: "script", scriptName: "no-such-script", scriptArgs: {} }),
310
+ ]);
311
+ const result = await runSeeder(seeder, { quiet: true });
312
+ expect(result.created).toBe(0);
313
+ expect(result.failed[0]?.error).toContain('Global script "no-such-script" not found');
314
+ });
315
+
316
+ test("fails a workflow target whose workflow does not exist, without creating a row", async () => {
317
+ const result = await runSeeder(
318
+ createSchedulesSeeder([
319
+ targetScheduleSource({ targetType: "workflow", workflow: "missing" }),
320
+ ]),
321
+ { quiet: true },
322
+ );
323
+ expect(result.created).toBe(0);
324
+ expect(result.failed[0]?.error).toContain('Workflow "missing" not found');
325
+ expect(await getScheduledTaskByName("test-target-schedule")).toBeNull();
326
+ });
327
+
328
+ test("rejects target blocks that ship fields or prompts the target ignores", () => {
329
+ const cases: [Record<string, unknown>, string | undefined, string][] = [
330
+ [{ targetType: "workflow", workflow: "w" }, "Do it.", "ships a ## Scheduled Task prompt"],
331
+ [{ targetType: "script", scriptName: "s" }, "Do it.", "ships a ## Scheduled Task prompt"],
332
+ [{ targetType: "workflow" }, undefined, "has no workflow name"],
333
+ [{ targetType: "script" }, undefined, "has no scriptName"],
334
+ [{ targetType: "workflow", workflow: "w", scriptName: "s" }, undefined, "sets scriptName"],
335
+ [{ workflow: "w" }, "Do it.", "(targetType agent-task) sets workflow"],
336
+ [{ targetType: "lambda" }, undefined, 'unknown targetType "lambda"'],
337
+ ];
338
+ for (const [target, prompt, message] of cases) {
339
+ expect(() => loadSeedSchedules([targetScheduleSource(target, prompt)])).toThrow(message);
340
+ }
341
+ });
342
+
343
+ test("migrates a pristine agent-task schedule to a workflow target and clears its prompt", async () => {
344
+ const workflow = await makeWorkflow("test-target-workflow");
345
+ await runSeeder(createSchedulesSeeder([targetScheduleSource({}, "Trigger the workflow.")]), {
346
+ quiet: true,
347
+ });
348
+ expect(await getScheduledTaskByName("test-target-schedule")).toMatchObject({
349
+ targetType: "agent-task",
350
+ taskTemplate: "Trigger the workflow.",
351
+ });
352
+
353
+ const result = await runSeeder(
354
+ createSchedulesSeeder([
355
+ targetScheduleSource({ targetType: "workflow", workflow: "test-target-workflow" }),
356
+ ]),
357
+ { quiet: true },
358
+ );
359
+ expect(result).toMatchObject({ updated: 1, failed: [] });
360
+ const row = await getScheduledTaskByName("test-target-schedule");
361
+ expect(row).toMatchObject({ targetType: "workflow", workflowId: workflow.id });
362
+ expect(row?.taskTemplate).toBeUndefined();
363
+ });
364
+
365
+ test("preserves an operator-edited agent-task schedule when its template moves to a workflow", async () => {
366
+ await makeWorkflow("test-target-workflow");
367
+ await runSeeder(createSchedulesSeeder([targetScheduleSource({}, "Trigger the workflow.")]), {
368
+ quiet: true,
369
+ });
370
+ const seeded = await getScheduledTaskByName("test-target-schedule");
371
+ await updateScheduledTask(seeded!.id, { taskTemplate: "My own prompt." });
372
+
373
+ const result = await runSeeder(
374
+ createSchedulesSeeder([
375
+ targetScheduleSource({ targetType: "workflow", workflow: "test-target-workflow" }),
376
+ ]),
377
+ { quiet: true },
378
+ );
379
+ expect(result.skippedUserModified).toBe(1);
380
+ expect(await getScheduledTaskByName("test-target-schedule")).toMatchObject({
381
+ targetType: "agent-task",
382
+ taskTemplate: "My own prompt.",
383
+ });
384
+ });
385
+
248
386
  test("reports an invalid cron without creating a row", async () => {
249
387
  const result = await runSeeder(createSchedulesSeeder([scheduleSource("not a cron")]), {
250
388
  quiet: true,
@@ -261,3 +261,42 @@ describe("nan and helmcode sessions price at $0 from the seeded table", () => {
261
261
  }
262
262
  });
263
263
  });
264
+
265
+ describe("pi on the ChatGPT subscription prices like codex on the same pool", () => {
266
+ const cache = loadModelsDevCache();
267
+ const rows = cache ? buildModelsDevSeedRows(cache) : [];
268
+ const book: RateBook = Object.fromEntries(
269
+ rows.map((row) => [`${row.provider}:${row.model}:${row.tokenClass}`, row.pricePerMillionUsd]),
270
+ );
271
+
272
+ test("openai-codex/<id> on pi records the codex <id> pricing-table cost", async () => {
273
+ // One request: 69,308 input tokens, 55,680 of them cached, 376 output.
274
+ // codex reports input including cache reads; pi-ai reports them disjoint.
275
+ const codex = await recomputeSessionCost(
276
+ {
277
+ ...BASE,
278
+ provider: "codex",
279
+ model: "gpt-5.6-luna",
280
+ inputTokens: 69_308,
281
+ cacheReadTokens: 55_680,
282
+ outputTokens: 376,
283
+ },
284
+ lookupFrom(book),
285
+ );
286
+ const pi = await recomputeSessionCost(
287
+ {
288
+ ...BASE,
289
+ provider: "pi",
290
+ model: "openai-codex/gpt-5.6-luna",
291
+ inputTokens: 69_308 - 55_680,
292
+ cacheReadTokens: 55_680,
293
+ outputTokens: 376,
294
+ },
295
+ lookupFrom(book),
296
+ );
297
+ expect(codex.costSource).toBe("pricing-table");
298
+ expect(pi.costSource).toBe("pricing-table");
299
+ expect(pi.totalCostUsd).toBeCloseTo(codex.totalCostUsd, 12);
300
+ expect(pi.totalCostUsd).toBeGreaterThan(0);
301
+ });
302
+ });
@@ -361,6 +361,32 @@ describe("Phase 3 — session cost recompute golden fixtures", () => {
361
361
  expectExact(body.cost.totalCostUsd, expected);
362
362
  });
363
363
 
364
+ // Boot-seeded rows only: fx reports the swarm model string with its route
365
+ // prefix and no cache split, so input and output price at the table rate.
366
+ test("fx runs price from the seeded rates on each route", async () => {
367
+ const openRouter = await postCost({
368
+ sessionId: "fx-openrouter",
369
+ provider: "fx",
370
+ model: "openrouter/deepseek/deepseek-v4.1-flash",
371
+ totalCostUsd: 0,
372
+ inputTokens: 58_620,
373
+ outputTokens: 1_183,
374
+ });
375
+ expect(openRouter.cost.costSource).toBe("pricing-table");
376
+ expectExact(openRouter.cost.totalCostUsd, (58_620 * 0.14 + 1_183 * 0.42) / 1_000_000);
377
+
378
+ const openAi = await postCost({
379
+ sessionId: "fx-openai",
380
+ provider: "fx",
381
+ model: "gpt-5-mini",
382
+ totalCostUsd: 0,
383
+ inputTokens: 9_286,
384
+ outputTokens: 506,
385
+ });
386
+ expect(openAi.cost.costSource).toBe("pricing-table");
387
+ expectExact(openAi.cost.totalCostUsd, (9_286 * 0.25 + 506 * 2) / 1_000_000);
388
+ });
389
+
364
390
  test("legacy cache writes without a TTL split keep the 5m class", async () => {
365
391
  await seedModelRates("claude", "claude-legacy-cache-write", {
366
392
  input: 5,