@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
@@ -0,0 +1,194 @@
1
+ /**
2
+ * A pool refresh or persist failure must never print a token. The OpenAI
3
+ * token endpoint and the config store may echo the request body in an error
4
+ * response; that text reaches console logs, the thrown error and, through
5
+ * `buildPoolRevalidationFailureReason`, the task's failure reason.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
9
+ import { resetFetchForTesting, setFetchForTesting } from "../providers/codex-oauth/flow.js";
10
+ import { persistCodexOAuth, storeCodexOAuth } from "../providers/codex-oauth/storage.js";
11
+ import { loadChatgptSubscriptionCredential } from "../providers/codex-oauth/subscription.js";
12
+ import type { CodexOAuthCredentials } from "../providers/codex-oauth/types.js";
13
+ import { isCodexAuthFailureReason } from "../utils/codex-auth-failure";
14
+
15
+ const API_URL = "http://localhost:3013";
16
+ const OLD_REFRESH = "rt_synthetic_pool_refresh_0123456789abcdef";
17
+ const NEW_ACCESS = "at_synthetic_rotated_access_0123456789abcdef";
18
+ const NEW_REFRESH = "rt_synthetic_rotated_refresh_0123456789abcdef";
19
+ const SECRETS = [OLD_REFRESH, NEW_ACCESS, NEW_REFRESH];
20
+ const CONFIG = { apiUrl: API_URL, apiKey: "test-key", codexSlot: 0, model: "openai-codex/gpt-5.5" };
21
+
22
+ const originalFetch = globalThis.fetch;
23
+ let logged: string[] = [];
24
+ const spies: Array<{ mockRestore: () => void }> = [];
25
+
26
+ /** A transport error whose message echoes the request carrying the tokens. */
27
+ function rejectedPersist(): never {
28
+ throw new TypeError(
29
+ `fetch failed: socket closed while sending {"value":"{\\"access\\":\\"${NEW_ACCESS}\\",\\"refresh\\":\\"${NEW_REFRESH}\\"}"} (${encodeURIComponent(NEW_REFRESH)})`,
30
+ );
31
+ }
32
+
33
+ /** Config store holding one expired slot; `putResponse` answers the persist. */
34
+ function installConfigStore(putResponse: () => Response): void {
35
+ const creds: CodexOAuthCredentials = {
36
+ access: "at_expired",
37
+ refresh: OLD_REFRESH,
38
+ expires: Date.now() - 1000,
39
+ accountId: "acct-egress",
40
+ };
41
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
42
+ const href = url.toString();
43
+ const method = (init?.method ?? "GET").toUpperCase();
44
+ if (href.includes("/api/oauth/refresh-locks/")) {
45
+ return Response.json(method === "POST" ? { owner: "owner-1" } : { released: true });
46
+ }
47
+ if (method === "GET" && href.includes("/api/config/resolved")) {
48
+ return Response.json({ configs: [{ key: "codex_oauth_0", value: JSON.stringify(creds) }] });
49
+ }
50
+ if (method === "PUT" && href.includes("/api/config")) return putResponse();
51
+ if (method === "DELETE" && href.includes("/api/config")) return Response.json({});
52
+ throw new Error(`Unexpected fetch: ${method} ${href}`);
53
+ }) as typeof fetch;
54
+ }
55
+
56
+ async function failureOf(promise: Promise<unknown>): Promise<string> {
57
+ try {
58
+ await promise;
59
+ } catch (err) {
60
+ return err instanceof Error ? err.message : String(err);
61
+ }
62
+ throw new Error("expected a rejection");
63
+ }
64
+
65
+ function expectNoSecret(text: string): void {
66
+ for (const secret of SECRETS) {
67
+ expect(text).not.toContain(secret);
68
+ expect(text).not.toContain(encodeURIComponent(secret));
69
+ }
70
+ }
71
+
72
+ beforeEach(() => {
73
+ logged = [];
74
+ for (const method of ["log", "error", "warn"] as const) {
75
+ spies.push(
76
+ spyOn(console, method).mockImplementation((...args: unknown[]) => {
77
+ logged.push(args.map((a) => (typeof a === "string" ? a : Bun.inspect(a))).join(" "));
78
+ }),
79
+ );
80
+ }
81
+ });
82
+
83
+ afterEach(() => {
84
+ for (const spy of spies.splice(0)) spy.mockRestore();
85
+ globalThis.fetch = originalFetch;
86
+ resetFetchForTesting();
87
+ });
88
+
89
+ describe("pool refresh failure egress", () => {
90
+ test("a 401 echoing the submitted refresh token is redacted and still counts", async () => {
91
+ installConfigStore(() => Response.json({}));
92
+ setFetchForTesting((async (_url: string | URL | Request, init?: RequestInit) => {
93
+ const form = String(init?.body ?? "");
94
+ return new Response(
95
+ JSON.stringify({ error: "invalid_grant", echo: OLD_REFRESH, request: form }),
96
+ { status: 401 },
97
+ );
98
+ }) as typeof fetch);
99
+
100
+ const reason = await failureOf(loadChatgptSubscriptionCredential(CONFIG));
101
+
102
+ expect(reason).toContain("refresh rejected (401");
103
+ expect(reason).toContain("invalid_grant");
104
+ expect(isCodexAuthFailureReason(reason)).toBe(true);
105
+ expectNoSecret(reason);
106
+ expect(logged.length).toBeGreaterThan(0);
107
+ expectNoSecret(logged.join("\n"));
108
+ });
109
+
110
+ test("a refresh response missing fields never logs the tokens it did carry", async () => {
111
+ installConfigStore(() => Response.json({}));
112
+ setFetchForTesting((async () =>
113
+ Response.json({ access_token: NEW_ACCESS, refresh_token: NEW_REFRESH })) as typeof fetch);
114
+
115
+ const reason = await failureOf(loadChatgptSubscriptionCredential(CONFIG));
116
+
117
+ expectNoSecret(reason);
118
+ expectNoSecret(logged.join("\n"));
119
+ });
120
+
121
+ test("a persist failure echoing the rotated tokens is redacted and stays transient", async () => {
122
+ installConfigStore(
123
+ () => new Response(`rejected value ${NEW_ACCESS} ${NEW_REFRESH}`, { status: 500 }),
124
+ );
125
+ setFetchForTesting((async () =>
126
+ Response.json({
127
+ access_token: NEW_ACCESS,
128
+ refresh_token: NEW_REFRESH,
129
+ expires_in: 864000,
130
+ })) as typeof fetch);
131
+
132
+ const reason = await failureOf(loadChatgptSubscriptionCredential(CONFIG));
133
+
134
+ expect(reason).toContain("HTTP 500");
135
+ expect(isCodexAuthFailureReason(reason)).toBe(false);
136
+ expectNoSecret(reason);
137
+ expectNoSecret(logged.join("\n"));
138
+ });
139
+
140
+ test("a persist fetch rejection echoing the rotated tokens never reaches the reason or logs", async () => {
141
+ installConfigStore(rejectedPersist);
142
+ setFetchForTesting((async () =>
143
+ Response.json({
144
+ access_token: NEW_ACCESS,
145
+ refresh_token: NEW_REFRESH,
146
+ expires_in: 864000,
147
+ })) as typeof fetch);
148
+
149
+ const reason = await failureOf(loadChatgptSubscriptionCredential(CONFIG));
150
+
151
+ expect(reason).toContain("Failed to store codex_oauth_0 config: fetch failed");
152
+ expect(isCodexAuthFailureReason(reason)).toBe(false);
153
+ expectNoSecret(reason);
154
+ expectNoSecret(logged.join("\n"));
155
+ });
156
+ });
157
+
158
+ describe("persist egress", () => {
159
+ const rotated: CodexOAuthCredentials = {
160
+ access: NEW_ACCESS,
161
+ refresh: NEW_REFRESH,
162
+ expires: Date.now() + 864000_000,
163
+ accountId: "acct-egress",
164
+ };
165
+
166
+ test("storeCodexOAuth rethrows a fetch rejection with no token and no raw cause", async () => {
167
+ globalThis.fetch = (async () => rejectedPersist()) as unknown as typeof fetch;
168
+
169
+ let thrown: unknown;
170
+ try {
171
+ await storeCodexOAuth(API_URL, "test-key", rotated, 0);
172
+ } catch (err) {
173
+ thrown = err;
174
+ }
175
+
176
+ expect(thrown).toBeInstanceOf(Error);
177
+ const err = thrown as Error;
178
+ expect(err.message).toContain("fetch failed");
179
+ expect(err.cause).toBeUndefined();
180
+ expectNoSecret(Bun.inspect(err));
181
+ });
182
+
183
+ test("persistCodexOAuth logs a fetch rejection or an echoing response without tokens", async () => {
184
+ globalThis.fetch = (async () => rejectedPersist()) as unknown as typeof fetch;
185
+ await persistCodexOAuth(API_URL, "test-key", rotated, 0);
186
+ globalThis.fetch = (async () =>
187
+ new Response(`rejected value ${NEW_ACCESS} ${NEW_REFRESH}`, { status: 500 })) as typeof fetch;
188
+ await persistCodexOAuth(API_URL, "test-key", rotated, 0);
189
+
190
+ expect(logged.join("\n")).toContain("fetch failed");
191
+ expect(logged.join("\n")).toContain("HTTP 500");
192
+ expectNoSecret(logged.join("\n"));
193
+ });
194
+ });
@@ -41,6 +41,13 @@ describe("getContextWindowSize", () => {
41
41
  expect(getContextWindowSize("gpt-5")).toBe(400_000);
42
42
  });
43
43
 
44
+ test("reads a ChatGPT subscription id's window from its OpenAI id, as codex does", () => {
45
+ expect(getContextWindowSize("openai-codex/gpt-5.6-luna")).toBe(
46
+ getContextWindowSize("gpt-5.6-luna"),
47
+ );
48
+ expect(getContextWindowSize("openai-codex/gpt-5.6-luna")).not.toBe(200_000);
49
+ });
50
+
44
51
  test("returns 200K default for unknown models", () => {
45
52
  expect(getContextWindowSize("unknown-model")).toBe(200_000);
46
53
  expect(getContextWindowSize("")).toBe(200_000);
@@ -331,6 +331,44 @@ describe("grok model namespace", () => {
331
331
  });
332
332
  });
333
333
 
334
+ describe("fx model routes", () => {
335
+ test("an unrouted namespace, a bare non-OpenAI id or latest:anthropic is refused, even with allowCustomModel", async () => {
336
+ for (const [model, reason] of [
337
+ ["nan/glm5.3-flash", "InconsistentFinishReason"],
338
+ ["helmcode/qwen3.6", "Helmcode is not verified on fx"],
339
+ ["anthropic/claude-opus-5-5", "Anthropic Messages API"],
340
+ ["claude-opus-5-5", "is in the anthropic catalog"],
341
+ ["latest:anthropic/opus", "latest:anthropic alias"],
342
+ ] as const) {
343
+ for (const allowCustomModel of [false, true]) {
344
+ const error = await explicitModelError({ model, harnessProvider: "fx", allowCustomModel });
345
+ expect(error).toContain(`Model "${model}" does not run on the fx harness`);
346
+ expect(error).toContain(reason);
347
+ }
348
+ }
349
+ });
350
+
351
+ test("the OpenRouter, AI Gateway and OpenAI routes pass", async () => {
352
+ for (const model of [
353
+ "openrouter/deepseek/deepseek-v4.1-flash",
354
+ "vercel/anthropic/claude-opus-5.5",
355
+ "gpt-5-mini",
356
+ "latest:openrouter/deepseek/deepseek-v4.1-*",
357
+ ]) {
358
+ expect(await explicitModelError({ model, harnessProvider: "fx" })).toBeNull();
359
+ }
360
+ });
361
+
362
+ test("MODEL_TIER_FX_<TIER> refuses a model fx cannot route", () => {
363
+ expect(validateTierConfigValue("MODEL_TIER_FX_SMART", "nan/glm5.3-flash")).toContain(
364
+ 'model "nan/glm5.3-flash" does not run on the fx harness',
365
+ );
366
+ expect(
367
+ validateTierConfigValue("MODEL_TIER_FX_SMOL", "openrouter/deepseek/deepseek-v4.1-flash"),
368
+ ).toBeNull();
369
+ });
370
+ });
371
+
334
372
  describe("MODEL_TIER_<PROVIDER>_<TIER> values", () => {
335
373
  test("a model from another harness family is rejected on write", () => {
336
374
  expect(validateTierConfigValue("MODEL_TIER_CODEX_SMART", "claude-opus-5-5")).toBe(
@@ -766,3 +804,64 @@ describe("nan and helmcode catalog models", () => {
766
804
  expect(res.status).toBe(200);
767
805
  });
768
806
  });
807
+
808
+ describe("ChatGPT subscription models (openai-codex/<id>)", () => {
809
+ let piId = "";
810
+
811
+ beforeAll(async () => {
812
+ piId = (
813
+ await createAgent({
814
+ name: "validation-pi-subscription",
815
+ isLead: false,
816
+ status: "idle",
817
+ harnessProvider: "pi",
818
+ })
819
+ ).id;
820
+ });
821
+
822
+ test("known when the bare id is an openai catalog model", () => {
823
+ const catalog = { openai: { models: { "gpt-5.6-sol": {} } } };
824
+ expect(isKnownCatalogModel("openai-codex/gpt-5.6-sol", catalog)).toBe(true);
825
+ expect(isKnownCatalogModel("openai-codex/gpt-9", catalog)).toBe(false);
826
+ expect(isKnownCatalogModel("openai-codex/openai/gpt-5.6-sol", catalog)).toBe(false);
827
+ });
828
+
829
+ test("pi takes it; opencode, codex, claude and grok get a mismatch", async () => {
830
+ const model = "openai-codex/gpt-5.6-sol";
831
+ expect(await explicitModelError({ model, harnessProvider: "pi" })).toBeNull();
832
+ for (const harnessProvider of ["opencode", "codex", "claude", "grok"]) {
833
+ const error = await explicitModelError({ model, harnessProvider, allowCustomModel: true });
834
+ expect(error).toContain(`does not run on the ${harnessProvider} harness`);
835
+ }
836
+ expect(await explicitModelError({ model, harnessProvider: "codex" })).toContain(
837
+ 'On codex, use "gpt-5.6-sol"',
838
+ );
839
+ });
840
+
841
+ test("send-task to a pi agent and the agent runtime PATCH accept it", async () => {
842
+ const sender = ownerCtx({ agentId: leadId });
843
+ const accepted = await sendTaskHandler(sender, {
844
+ task: "run on the chatgpt subscription",
845
+ offerMode: false,
846
+ allowDuplicate: false,
847
+ agentId: piId,
848
+ model: "openai-codex/gpt-5.6-sol",
849
+ routingReason: "human_pinned",
850
+ routingNote: "pinned to check the subscription model id",
851
+ });
852
+ expect({ ok: accepted.ok, message: accepted.message }).toEqual({
853
+ ok: true,
854
+ message: expect.any(String),
855
+ });
856
+
857
+ const res = await api("PATCH", `/api/agents/${piId}/runtime`, {
858
+ harness_provider: "pi",
859
+ model: "openai-codex/gpt-5.6-sol",
860
+ reasoning_effort: "high",
861
+ });
862
+ expect({ status: res.status, body: res.body }).toEqual({
863
+ status: 200,
864
+ body: expect.anything(),
865
+ });
866
+ });
867
+ });
@@ -24,6 +24,17 @@ describe("runContext", () => {
24
24
  expect(context.subject?.includes("sk-ant-")).toBe(false);
25
25
  });
26
26
 
27
+ test("steering records its source and sender agent without message content", () => {
28
+ expect(
29
+ runContext("pre.task.steer", {
30
+ task: { id: "task-1", agentId: "worker", task: "Private task" },
31
+ source: "mcp",
32
+ sender: { kind: "agent", agentId: "lead" },
33
+ message: "Private correction",
34
+ }),
35
+ ).toEqual({ agentId: "lead", subject: "mcp: task task-1" });
36
+ });
37
+
27
38
  test("post task events record the task's agent", () => {
28
39
  expect(
29
40
  runContext("post.task.completed", {
@@ -0,0 +1,353 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { createServer, type Server } from "node:http";
3
+ import {
4
+ closeDb,
5
+ createAgent,
6
+ createTaskExtended,
7
+ createUser,
8
+ getChildTasks,
9
+ getDbClient,
10
+ getKv,
11
+ getLogsByTaskId,
12
+ getSteeringMessagesForTask,
13
+ getTaskById,
14
+ initDb,
15
+ pauseTask,
16
+ startTask,
17
+ } from "../be/db";
18
+ import { getExtensionByName, installExtension, listExtensionRuns } from "../be/extensions/db";
19
+ import { requestSteering, SteeringBlockedError } from "../be/steering";
20
+ import type { TaskSteerEvent } from "../extensions/contract";
21
+ import { enableExtension, stopExtensionRuntime } from "../extensions/lifecycle";
22
+ import { handleMcpBridge } from "../http/mcp-bridge";
23
+ import { handleTasks } from "../http/tasks";
24
+ import { SwarmConfig } from "../scripts-runtime/swarm-config";
25
+ import { createSwarmSdk } from "../scripts-runtime/swarm-sdk";
26
+ import * as slackApp from "../slack/app";
27
+ import { requestSlackThreadSteering } from "../slack/steering";
28
+ import { bufferThreadMessage, instantFlush } from "../slack/thread-buffer";
29
+ import { steerTaskHandler } from "../tools/steer-task";
30
+ import { ownerCtx } from "../tools/task-tool-ctx";
31
+ import { loadBundleFixture } from "./fixtures/extensions/load";
32
+ import { listenOnFreePort } from "./test-net";
33
+
34
+ const TEST_DB_PATH = `./test-extensions-steering-${process.pid}.sqlite`;
35
+ const ENV_KEYS = [
36
+ "STEERING_ENABLED",
37
+ "SLACK_THREAD_STEERING",
38
+ "SLACK_THREAD_STEERING_MODE",
39
+ "SLACK_RENDER_V2",
40
+ ];
41
+ const savedEnv = new Map(ENV_KEYS.map((key) => [key, process.env[key]]));
42
+ let leadId: string;
43
+ let server: Server;
44
+ let baseUrl: string;
45
+
46
+ async function removeDbFiles() {
47
+ for (const suffix of ["", "-wal", "-shm"]) {
48
+ await Bun.file(TEST_DB_PATH + suffix)
49
+ .delete()
50
+ .catch(() => {});
51
+ }
52
+ }
53
+
54
+ async function installPolicy(config: Record<string, unknown>, name = "steering-policy") {
55
+ const bundle = await loadBundleFixture("steering-policy");
56
+ const installed = await installExtension({
57
+ ...bundle,
58
+ manifest: { ...bundle.manifest, name },
59
+ config,
60
+ });
61
+ return await enableExtension(installed.extension.id);
62
+ }
63
+
64
+ async function runningTask() {
65
+ const task = await createTaskExtended("Existing task", {
66
+ agentId: leadId,
67
+ source: "slack",
68
+ slackChannelId: "C_STEER_POLICY",
69
+ slackThreadTs: "1000.0001",
70
+ });
71
+ await startTask(task.id);
72
+ return task;
73
+ }
74
+
75
+ async function lastEvent(name = "steering-policy"): Promise<TaskSteerEvent | undefined> {
76
+ const extension = await getExtensionByName(name);
77
+ return (await getKv(`task:agent:${extension?.agentId}`, `ext:${name}:last`))?.value as
78
+ | TaskSteerEvent
79
+ | undefined;
80
+ }
81
+
82
+ async function expectNoSteering(taskId: string) {
83
+ expect(await getSteeringMessagesForTask(taskId)).toEqual([]);
84
+ expect(
85
+ (await getLogsByTaskId(taskId)).filter((log) => log.eventType === "task_steering"),
86
+ ).toEqual([]);
87
+ expect(await getChildTasks(taskId)).toEqual([]);
88
+ }
89
+
90
+ describe("extension steering boundary", () => {
91
+ beforeAll(async () => {
92
+ process.env.STEERING_ENABLED = "true";
93
+ process.env.SLACK_THREAD_STEERING = "lead";
94
+ process.env.SLACK_THREAD_STEERING_MODE = "queue";
95
+ process.env.SLACK_RENDER_V2 = "false";
96
+ await removeDbFiles();
97
+ initDb(TEST_DB_PATH);
98
+ server = createServer(async (req, res) => {
99
+ res.setHeader("Content-Type", "application/json");
100
+ const path = new URL(req.url ?? "/", "http://localhost").pathname.split("/").filter(Boolean);
101
+ if (await handleMcpBridge(req, res, path, new URLSearchParams(), leadId)) return;
102
+ await handleTasks(req, res, path, new URLSearchParams(), leadId);
103
+ });
104
+ baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
105
+ });
106
+
107
+ beforeEach(async () => {
108
+ await stopExtensionRuntime();
109
+ const client = getDbClient();
110
+ await client.run("DELETE FROM agent_tasks");
111
+ await client.run("DELETE FROM extensions");
112
+ await client.run("DELETE FROM kv_entries WHERE key LIKE 'ext:%'");
113
+ await client.run("DELETE FROM agents");
114
+ leadId = (
115
+ await createAgent({
116
+ name: "Steering policy lead",
117
+ isLead: true,
118
+ status: "busy",
119
+ maxTasks: 10,
120
+ harnessProvider: "pi",
121
+ })
122
+ ).id;
123
+ });
124
+
125
+ afterAll(async () => {
126
+ await stopExtensionRuntime();
127
+ await new Promise<void>((resolve) => server.close(() => resolve()));
128
+ closeDb();
129
+ await removeDbFiles();
130
+ for (const [key, value] of savedEnv) {
131
+ if (value === undefined) delete process.env[key];
132
+ else process.env[key] = value;
133
+ }
134
+ });
135
+
136
+ test("continue records the task, source, mode and sender before queuing the original text", async () => {
137
+ await installPolicy({ action: "continue" });
138
+ const task = await runningTask();
139
+ const result = await requestSteering({
140
+ taskId: task.id,
141
+ message: "Add the charts",
142
+ mode: "steer",
143
+ source: "mcp",
144
+ createdByKind: "agent",
145
+ createdByAgentId: leadId,
146
+ });
147
+ expect(result.outcome).toBe("steered");
148
+ expect(await lastEvent()).toMatchObject({
149
+ task: { id: task.id, status: "in_progress" },
150
+ message: "Add the charts",
151
+ mode: "steer",
152
+ source: "mcp",
153
+ sender: { kind: "agent", agentId: leadId },
154
+ });
155
+ expect((await getSteeringMessagesForTask(task.id))[0]?.body).toBe("Add the charts");
156
+ });
157
+
158
+ test("valid modifications flow through successive handlers and are the only text delivered", async () => {
159
+ await installPolicy({ action: "modify", prefix: "first: " });
160
+ await installPolicy({ action: "modify", prefix: "second: " }, "steering-policy-z");
161
+ const task = await runningTask();
162
+ await requestSteering({ taskId: task.id, message: "Add the charts" });
163
+ expect((await lastEvent("steering-policy-z"))?.message).toBe("first: Add the charts");
164
+ expect((await getSteeringMessagesForTask(task.id))[0]?.body).toBe(
165
+ "second: first: Add the charts",
166
+ );
167
+ });
168
+
169
+ test("block returns an actionable refusal without steering rows, logs or follow-up tasks", async () => {
170
+ const extension = await installPolicy({ action: "block" });
171
+ const task = await runningTask();
172
+ try {
173
+ await requestSteering({ taskId: task.id, message: "Do not deliver" });
174
+ throw new Error("Expected steering to be blocked");
175
+ } catch (error) {
176
+ expect(error).toBeInstanceOf(SteeringBlockedError);
177
+ expect(error).toMatchObject({
178
+ statusCode: 403,
179
+ extension: { id: extension.id },
180
+ reason: "Steering denied by policy",
181
+ });
182
+ }
183
+ await expectNoSteering(task.id);
184
+ expect((await listExtensionRuns(extension.id)).map((run) => run.action)).toEqual(["block"]);
185
+ });
186
+
187
+ test("a blocked paused task is not resumed", async () => {
188
+ await installPolicy({ action: "block" });
189
+ const task = await runningTask();
190
+ await pauseTask(task.id);
191
+ await expect(
192
+ requestSteering({ taskId: task.id, message: "Do not resume" }),
193
+ ).rejects.toBeInstanceOf(SteeringBlockedError);
194
+ expect((await getTaskById(task.id))?.status).toBe("paused");
195
+ await expectNoSteering(task.id);
196
+ });
197
+
198
+ for (const action of ["empty", "retarget", "throw"]) {
199
+ test(`${action} fails open and records an extension error`, async () => {
200
+ const extension = await installPolicy({ action });
201
+ const task = await runningTask();
202
+ await requestSteering({ taskId: task.id, message: "Keep the original" });
203
+ expect((await getSteeringMessagesForTask(task.id))[0]?.body).toBe("Keep the original");
204
+ expect((await listExtensionRuns(extension.id))[0]?.action).toBe("error");
205
+ });
206
+ }
207
+
208
+ test("valid rewritten text is secret-scrubbed before storage", async () => {
209
+ const secret = `sk-ant-${"a".repeat(40)}`;
210
+ await installPolicy({ action: "modify", prefix: `${secret} ` });
211
+ const task = await runningTask();
212
+ await requestSteering({ taskId: task.id, message: "Keep this" });
213
+ const body = (await getSteeringMessagesForTask(task.id))[0]?.body;
214
+ expect(body).not.toContain(secret);
215
+ expect(body).toContain("Keep this");
216
+ });
217
+
218
+ test("extension-originated steering skips its own handler", async () => {
219
+ const extension = await installPolicy({ action: "block" });
220
+ const task = await runningTask();
221
+ await requestSteering({
222
+ taskId: task.id,
223
+ message: "From the extension",
224
+ createdByKind: "agent",
225
+ createdByAgentId: extension.agentId!,
226
+ });
227
+ expect(await lastEvent()).toBeUndefined();
228
+ expect((await getSteeringMessagesForTask(task.id))[0]?.body).toBe("From the extension");
229
+ });
230
+
231
+ test("HTTP steering returns 403 for a policy block", async () => {
232
+ await installPolicy({ action: "block" });
233
+ const task = await runningTask();
234
+ const response = await fetch(`${baseUrl}/api/tasks/${task.id}/steer`, {
235
+ method: "POST",
236
+ headers: { "Content-Type": "application/json", "X-Agent-ID": leadId },
237
+ body: JSON.stringify({ message: "Do not deliver" }),
238
+ });
239
+ expect(response.status).toBe(403);
240
+ expect(await response.text()).toContain("Steering denied by policy");
241
+ await expectNoSteering(task.id);
242
+ });
243
+
244
+ test("MCP steering reports a policy block as a tool error", async () => {
245
+ await installPolicy({ action: "block" });
246
+ const task = await runningTask();
247
+ const result = await steerTaskHandler(ownerCtx({ agentId: leadId }), {
248
+ taskId: task.id,
249
+ message: "Do not deliver",
250
+ });
251
+ expect(result.ok).toBe(false);
252
+ expect(result.message).toContain("Steering denied by policy");
253
+ expect(await lastEvent()).toMatchObject({ source: "mcp" });
254
+ await expectNoSteering(task.id);
255
+ });
256
+
257
+ test("script SDK steering reaches the hook through the bridge with the script source", async () => {
258
+ await installPolicy({ action: "continue" });
259
+ const task = await runningTask();
260
+ const sdk = createSwarmSdk(
261
+ new SwarmConfig({
262
+ system: {
263
+ apiKey: { value: "steering-sdk-test-key", isSecret: true },
264
+ agentId: { value: leadId, isSecret: false },
265
+ mcpBaseUrl: { value: baseUrl, isSecret: false },
266
+ },
267
+ user: {},
268
+ }),
269
+ );
270
+
271
+ const result = await sdk.task_steer({ taskId: task.id, message: "From the script SDK" });
272
+
273
+ expect(result).toMatchObject({
274
+ success: true,
275
+ status: 200,
276
+ data: { success: true, outcome: "queued" },
277
+ });
278
+ expect(await lastEvent()).toMatchObject({
279
+ task: { id: task.id },
280
+ message: "From the script SDK",
281
+ mode: "queue",
282
+ source: "script",
283
+ sender: { kind: "agent", agentId: leadId },
284
+ });
285
+ expect(await getSteeringMessagesForTask(task.id)).toEqual([
286
+ expect.objectContaining({ source: "script", body: "From the script SDK" }),
287
+ ]);
288
+ });
289
+
290
+ test("Slack supplies message timestamps and sender metadata without acknowledgement log rows on block", async () => {
291
+ await installPolicy({ action: "block" });
292
+ const task = await runningTask();
293
+ const user = await createUser({ name: "Steering sender" });
294
+ await expect(
295
+ requestSlackThreadSteering({
296
+ channelId: "C_STEER_POLICY",
297
+ threadTs: "1000.0001",
298
+ message: "Do not deliver",
299
+ messageTimestamps: ["1000.0002", "1000.0003"],
300
+ requestedByUserId: user.id,
301
+ slackUserId: "U_SENDER",
302
+ }),
303
+ ).rejects.toBeInstanceOf(SteeringBlockedError);
304
+ expect(await lastEvent()).toMatchObject({
305
+ source: "slack",
306
+ sender: { kind: "user", userId: user.id },
307
+ messageTimestamps: ["1000.0002", "1000.0003"],
308
+ slack: { channelId: "C_STEER_POLICY", threadTs: "1000.0001", userId: "U_SENDER" },
309
+ });
310
+ await expectNoSteering(task.id);
311
+ });
312
+
313
+ test("a blocked buffer flush is consumed and later senders still get delivered", async () => {
314
+ await installPolicy({ action: "block", slackUserId: "U_BLOCKED" });
315
+ const task = await runningTask();
316
+ const acked: string[] = [];
317
+ const posted: string[] = [];
318
+ const appSpy = spyOn(slackApp, "getSlackApp").mockReturnValue({
319
+ client: {
320
+ users: { info: async () => ({ ok: false }) },
321
+ reactions: {
322
+ add: async (args: { timestamp: string; name: string }) => {
323
+ acked.push(`${args.timestamp}:${args.name}`);
324
+ return { ok: true };
325
+ },
326
+ remove: async () => ({ ok: true }),
327
+ },
328
+ chat: {
329
+ postMessage: async (args: { text: string }) => {
330
+ posted.push(args.text);
331
+ return { ok: true };
332
+ },
333
+ },
334
+ },
335
+ } as unknown as NonNullable<ReturnType<typeof slackApp.getSlackApp>>);
336
+ try {
337
+ bufferThreadMessage("C_STEER_POLICY", "1000.0001", "Drop this", "U_BLOCKED", "1000.0002");
338
+ bufferThreadMessage("C_STEER_POLICY", "1000.0001", "Keep this", "U_ALLOWED", "1000.0003");
339
+ await instantFlush("C_STEER_POLICY:1000.0001");
340
+ expect((await getSteeringMessagesForTask(task.id)).map((row) => row.body)).toEqual([
341
+ "Keep this",
342
+ ]);
343
+ expect(acked).toContain("1000.0002:no_entry");
344
+ expect(posted).toHaveLength(1);
345
+ expect(await getChildTasks(task.id)).toEqual([]);
346
+ expect(
347
+ (await getLogsByTaskId(task.id)).filter((log) => log.newValue === "slack_reaction"),
348
+ ).toEqual([expect.objectContaining({ metadata: expect.stringContaining("1000.0003") })]);
349
+ } finally {
350
+ appSpy.mockRestore();
351
+ }
352
+ });
353
+ });