@desplega.ai/agent-swarm 1.152.2 → 1.154.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/README.md +2 -2
  2. package/dist/{acp-adapter-pfeje2v5.js → acp-adapter-eb2aj8xy.js} +4 -4
  3. package/dist/{actions-0sttc7f5.js → actions-kpnwp1kz.js} +31 -27
  4. package/dist/{anthropic-messages-2y8nktar.js → anthropic-messages-9vqsbznt.js} +112 -84
  5. package/dist/{app-p8teva95.js → app-zk1apkf6.js} +10 -7
  6. package/dist/{assistant-r45jnsrt.js → assistant-kwq4y50q.js} +32 -28
  7. package/dist/{azure-openai-responses-f38x2avq.js → azure-openai-responses-04qwdwfq.js} +27 -13
  8. package/dist/{boot-reembed-je26h5y7.js → boot-reembed-22hd3jjn.js} +9 -7
  9. package/dist/{boot-reembed-1qmybsd4.js → boot-reembed-qe168424.js} +10 -8
  10. package/dist/{boot-scrub-logs-gte3h1mm.js → boot-scrub-logs-pkdpsrjd.js} +7 -5
  11. package/dist/{claude-adapter-csc1mzka.js → claude-adapter-3fx90nww.js} +18 -16
  12. package/dist/{claude-managed-adapter-dsmkcrjy.js → claude-managed-adapter-ea2aga6w.js} +3 -3
  13. package/dist/{claude-sdk-session-fcd6f078.js → claude-sdk-session-z7fahf1k.js} +18 -16
  14. package/dist/{cli-r2fpe7cx.js → cli-00w2c66p.js} +61 -55
  15. package/dist/{cli-wbyk465z.js → cli-09y3tjxz.js} +1 -1
  16. package/dist/{cli-ht5rs63n.js → cli-0e31nm4n.js} +16 -7
  17. package/dist/cli-0x5qzvkg.js +4279 -0
  18. package/dist/{cli-d3p82erb.js → cli-18amys3y.js} +9 -9
  19. package/dist/{cli-4ahpr4sw.js → cli-1jpjafw4.js} +5 -5
  20. package/dist/{cli-jbmc0r7r.js → cli-243tzws8.js} +1 -1
  21. package/dist/{cli-ch38q5z4.js → cli-24k806sc.js} +6789 -2542
  22. package/dist/cli-3bkz2p7j.js +2828 -0
  23. package/dist/cli-3er6gcsn.js +1849 -0
  24. package/dist/cli-4k7jazkq.js +3339 -0
  25. package/dist/{cli-c4eeb5q2.js → cli-5b6b62tz.js} +1 -1
  26. package/dist/{cli-qd65dvr6.js → cli-5vs0nh43.js} +65 -44
  27. package/dist/{cli-z3wkng35.js → cli-6r3cgpk2.js} +224 -156
  28. package/dist/{cli-pgj4a5fb.js → cli-71cj99hx.js} +4 -3
  29. package/dist/cli-8sea27q4.js +14075 -0
  30. package/dist/{cli-jvy9qgjs.js → cli-913yakhv.js} +22 -7
  31. package/dist/{cli-pf9q3696.js → cli-927erqqt.js} +5 -2
  32. package/dist/cli-95jnpnqg.js +230 -0
  33. package/dist/{cli-wpv5rgg2.js → cli-abp3g4ax.js} +15 -2
  34. package/dist/{cli-ckz5t2hb.js → cli-acy6bfhr.js} +10 -7
  35. package/dist/{cli-4yc5d8fk.js → cli-avkd6mqv.js} +50 -0
  36. package/dist/{cli-8wrympd7.js → cli-bzr0ftht.js} +26 -7
  37. package/dist/{cli-qn042eh7.js → cli-c1v10xb2.js} +194 -1913
  38. package/dist/cli-cgj4hc12.js +18902 -0
  39. package/dist/{cli-2nnd56zt.js → cli-e0pg4zeh.js} +3 -2
  40. package/dist/{cli-r84ctdxn.js → cli-e31ehqwy.js} +2 -2
  41. package/dist/{cli-gphgnvpf.js → cli-e3229na9.js} +3 -3
  42. package/dist/{cli-gpqcs935.js → cli-e3cyf59d.js} +2 -2
  43. package/dist/{cli-s25avqg9.js → cli-e781c6f9.js} +1 -0
  44. package/dist/{cli-s2qjdd6z.js → cli-eetrvv91.js} +1 -1
  45. package/dist/{cli-j4nv2wqy.js → cli-fdzg6jhf.js} +10 -4
  46. package/dist/{cli-fm4ytsdk.js → cli-g2h29qar.js} +2 -2
  47. package/dist/{cli-zvgj5zg8.js → cli-ge67p080.js} +302 -3497
  48. package/dist/cli-h38x0qhg.js +6912 -0
  49. package/dist/{cli-9xvdm3bx.js → cli-h60qfasa.js} +742 -278
  50. package/dist/{cli-jew3jda5.js → cli-hws0vn86.js} +2 -2
  51. package/dist/{cli-j8dggbcr.js → cli-k0g1sjzm.js} +23 -31
  52. package/dist/{cli-xw7vak96.js → cli-kp3qs53m.js} +5 -5
  53. package/dist/{cli-rgvp6r2x.js → cli-mcnhgnn1.js} +193 -4
  54. package/dist/{cli-zvkq5csn.js → cli-myaxtgh8.js} +7 -6
  55. package/dist/{cli-56h18ng2.js → cli-n62atwh9.js} +1 -1
  56. package/dist/cli-n7h1kqpf.js +29 -0
  57. package/dist/{cli-fmy65jdn.js → cli-pf1v8xhb.js} +7 -5
  58. package/dist/{cli-xy1cchaf.js → cli-q85qhckh.js} +238 -11
  59. package/dist/{cli-3bpzj8me.js → cli-r1d3dzt6.js} +2 -2
  60. package/dist/{pi-mono-adapter-jcqt71cg.js → cli-rxkqvngf.js} +67343 -101083
  61. package/dist/{cli-kj76p1ez.js → cli-scwq51gn.js} +13 -7
  62. package/dist/{cli-b9dd0h3k.js → cli-stwsmd6k.js} +3 -2
  63. package/dist/{cli-c2mk1kgf.js → cli-v4en75c5.js} +8 -1
  64. package/dist/{cli-z61ykvam.js → cli-wg04s9kr.js} +4 -3
  65. package/dist/cli-yn0a417a.js +4069 -0
  66. package/dist/{cli-zfzznn7c.js → cli-ytxs7mn1.js} +26 -2
  67. package/dist/{cli-4j8k1m4w.js → cli-zt1227k1.js} +1162 -192
  68. package/dist/cli.js +28 -35
  69. package/dist/{codex-adapter-fxmpsabw.js → codex-adapter-ttdm995v.js} +12 -11
  70. package/dist/{codex-hook-8jexc34x.js → codex-hook-8gx7bcz0.js} +3 -3
  71. package/dist/{codex-session-runner-rr1e47ea.js → codex-session-runner-3v8nh4c8.js} +12 -11
  72. package/dist/{commands-0f5rxz51.js → commands-1cxwbhhq.js} +7 -5
  73. package/dist/{db-r3jxf3wy.js → db-y9zwsr79.js} +25 -5
  74. package/dist/{dispatcher-06vph6q4.js → dispatcher-9m9zss36.js} +30 -26
  75. package/dist/dsh-adapter-gsb2qqf9.js +19 -0
  76. package/dist/{e2b-413b8t4v.js → e2b-93x1k0j5.js} +6 -7
  77. package/dist/{google-generative-ai-ndjmj5fa.js → google-generative-ai-gwwrnzqn.js} +33 -73
  78. package/dist/{google-vertex-fr665tqs.js → google-vertex-5wfawpwz.js} +38 -68
  79. package/dist/{handlers-0hgtc910.js → handlers-36fm1bwn.js} +33 -29
  80. package/dist/{hook-9kvrv1er.js → hook-pntw4r8h.js} +12 -10
  81. package/dist/{hook-fbfhkat3.js → hook-vwq8y26p.js} +13 -11
  82. package/dist/{http-xbyx8k5j.js → http-20jesakk.js} +357 -261
  83. package/dist/{index-jsw3aynb.js → index-6artrehg.js} +30 -26
  84. package/dist/{cli-pdz9qde4.js → index-9ch1n761.js} +10 -11
  85. package/dist/{index-rnyahfn5.js → index-9n5z2492.js} +30 -26
  86. package/dist/{index-66xz5e9j.js → index-h5gs261z.js} +521 -9
  87. package/dist/{index-tfsdxyv6.js → index-j5wrf1yw.js} +40 -25
  88. package/dist/{index-2sp0384z.js → index-kkeftv5h.js} +738 -21
  89. package/dist/{index-sgk50mbg.js → index-m2xqpz5r.js} +31 -27
  90. package/dist/{index-gpfn1741.js → index-m9ks8a7t.js} +9 -7
  91. package/dist/index-w097eg0x.js +5 -0
  92. package/dist/jiti-loader-b4837mtm.js +34 -0
  93. package/dist/jiti-static-loader-q2g5ds0y.js +31267 -0
  94. package/dist/{keepalive-s3xpr7sw.js → keepalive-pwxvxd2c.js} +9 -7
  95. package/dist/{lead-rq4n5r16.js → lead-m789wqa3.js} +46 -41
  96. package/dist/{maintenance-f0atj1ab.js → maintenance-bvg6w7e2.js} +10 -8
  97. package/dist/{mistral-conversations-qhr84ph3.js → mistral-conversations-zx0kn554.js} +24 -18
  98. package/dist/{node-x1g4v0pq.js → node-5qtycf6a.js} +16 -11
  99. package/dist/{oauth-refresh-sweep-q0t3bxre.js → oauth-refresh-sweep-6v4bd737.js} +8 -6
  100. package/dist/{onboard-br5j1kch.js → onboard-snckpcj1.js} +115 -35
  101. package/dist/{openai-codex-responses-wy6a43wd.js → openai-codex-responses-4vjqzvjp.js} +33 -27
  102. package/dist/{openai-completions-32rdwmze.js → openai-completions-pxsmym7g.js} +52 -64
  103. package/dist/{openai-responses-6xwf7bzg.js → openai-responses-tmn0rqp0.js} +23 -24
  104. package/dist/{opencode-adapter-28axqa09.js → opencode-adapter-qbmv4tpy.js} +6 -6
  105. package/dist/{otel-impl-vdqge2nt.js → otel-impl-e0ytens5.js} +4 -4
  106. package/dist/{pi-messages-b1mbr6gp.js → pi-messages-txpjj39q.js} +3 -3
  107. package/dist/pi-mono-adapter-c4ggemhz.js +1316 -0
  108. package/dist/{pricing-refresh-hb3691fd.js → pricing-refresh-52g2ywrw.js} +9 -7
  109. package/dist/{rbac-roles-t78xt39b.js → rbac-roles-5455kfdn.js} +8 -6
  110. package/dist/{rbac-roles-jh280k9g.js → rbac-roles-hbf0xa52.js} +9 -7
  111. package/dist/{render-v2-7p8r2ymg.js → render-v2-npm8xdzh.js} +16 -9
  112. package/dist/{seed-pricing-hf889enh.js → seed-pricing-650wbr1n.js} +8 -6
  113. package/dist/{setup-w6mb03jv.js → setup-k1mew5rf.js} +4 -5
  114. package/dist/virtual-modules-2q5b2pk4.js +54 -0
  115. package/dist/{work-objects-nt8gy1kj.js → work-objects-2ea3kvwg.js} +33 -29
  116. package/dist/{worker-dwy617t0.js → worker-ec6hvc4m.js} +46 -41
  117. package/openapi.json +109 -16
  118. package/package.json +7 -6
  119. package/plugin/opencode-plugins/lib/memory-raters.ts +7 -0
  120. package/src/be/automation-preflight.ts +6 -4
  121. package/src/be/db/agents.ts +75 -2
  122. package/src/be/db/tasks/read.ts +2 -0
  123. package/src/be/db/tasks/write.ts +7 -1
  124. package/src/be/db.ts +140 -18
  125. package/src/be/extensions/db.ts +35 -4
  126. package/src/be/memory/raters/llm.ts +2 -6
  127. package/src/be/memory/raters/noop.ts +2 -3
  128. package/src/be/memory/raters/registry.ts +4 -10
  129. package/src/be/migrations/157_defer_provenance_and_marker.sql +27 -0
  130. package/src/be/models-catalog.ts +2 -0
  131. package/src/be/modelsdev-cache.json +213 -0
  132. package/src/be/rbac-audit.ts +2 -0
  133. package/src/be/seed/agent-fs-provision.ts +23 -0
  134. package/src/be/seed-pricing.ts +3 -1
  135. package/src/be/swarm-config-guard.ts +1 -0
  136. package/src/commands/onboard/compose-generator.ts +16 -1
  137. package/src/commands/onboard/env-generator.ts +14 -1
  138. package/src/commands/onboard/manifest.ts +1 -0
  139. package/src/commands/onboard/steps/integration-slack.tsx +56 -16
  140. package/src/commands/onboard/steps/review.tsx +5 -0
  141. package/src/commands/onboard/types.ts +6 -0
  142. package/src/commands/onboard.tsx +5 -1
  143. package/src/commands/provider-credentials.ts +14 -2
  144. package/src/commands/runner.ts +18 -2
  145. package/src/extensions/identity.ts +15 -7
  146. package/src/extensions/lifecycle.ts +15 -21
  147. package/src/hooks/codex-hook.ts +7 -1
  148. package/src/http/agents.ts +12 -4
  149. package/src/http/config.ts +1 -1
  150. package/src/http/core.ts +5 -3
  151. package/src/http/extensions.ts +108 -34
  152. package/src/http/poll.ts +7 -0
  153. package/src/http/schedules.ts +10 -0
  154. package/src/http/script-runs.ts +35 -26
  155. package/src/http/status.ts +24 -11
  156. package/src/http/tasks.ts +6 -1
  157. package/src/prompts/base-prompt.ts +15 -7
  158. package/src/prompts/memories.ts +5 -7
  159. package/src/prompts/session-templates.ts +3 -2
  160. package/src/prompts/steering-delivery.ts +2 -0
  161. package/src/providers/claude-managed-models.ts +8 -0
  162. package/src/providers/codex-adapter.ts +3 -6
  163. package/src/providers/codex-models.ts +15 -1
  164. package/src/providers/dsh-adapter.ts +248 -0
  165. package/src/providers/index.ts +5 -1
  166. package/src/providers/modelsdev-reasoning.json +50 -0
  167. package/src/providers/pi-mono-extension.ts +2 -5
  168. package/src/rbac/legacy-policy.ts +15 -1
  169. package/src/rbac/types.ts +2 -0
  170. package/src/server.ts +7 -1
  171. package/src/slack/ack.ts +43 -8
  172. package/src/slack/app.ts +32 -10
  173. package/src/slack/config.ts +29 -0
  174. package/src/slack/handlers.ts +7 -1
  175. package/src/slack/render-v2.ts +186 -17
  176. package/src/slack/responses.ts +1 -3
  177. package/src/slack/task-output.ts +66 -8
  178. package/src/slack/thread-buffer.ts +27 -3
  179. package/src/telemetry.ts +2 -3
  180. package/src/tests/agent-fs-provision-seeder.test.ts +56 -2
  181. package/src/tests/agents-harness-provider.test.ts +19 -0
  182. package/src/tests/agents-list-model-display.test.ts +60 -1
  183. package/src/tests/base-prompt.test.ts +25 -0
  184. package/src/tests/claude-managed-adapter.test.ts +6 -0
  185. package/src/tests/codex-gpt6-release.test.ts +97 -0
  186. package/src/tests/codex-steering-hook.test.ts +2 -2
  187. package/src/tests/config-api-only-keys.test.ts +9 -0
  188. package/src/tests/context-window.test.ts +1 -0
  189. package/src/tests/db-task-read-contract.test.ts +9 -0
  190. package/src/tests/db-task-write-contract.test.ts +9 -0
  191. package/src/tests/defer-task.test.ts +115 -16
  192. package/src/tests/dsh-adapter.test.ts +276 -0
  193. package/src/tests/e2e-nightly-report.test.ts +3 -3
  194. package/src/tests/e2e-redact.test.ts +2 -0
  195. package/src/tests/env-flag.test.ts +6 -0
  196. package/src/tests/extension-agent-visibility.test.ts +474 -0
  197. package/src/tests/extensions-db.test.ts +41 -0
  198. package/src/tests/extensions-http.test.ts +148 -4
  199. package/src/tests/extensions-lifecycle.test.ts +21 -1
  200. package/src/tests/extensions-mcp-tools.test.ts +9 -5
  201. package/src/tests/fetch-resolved-env.test.ts +43 -0
  202. package/src/tests/internal-ai/complete-structured.test.ts +32 -9
  203. package/src/tests/internal-ai/credentials.test.ts +4 -4
  204. package/src/tests/internal-ai/models.test.ts +20 -0
  205. package/src/tests/memory-rate-tool.test.ts +7 -10
  206. package/src/tests/memory-rater-defaults.test.ts +23 -0
  207. package/src/tests/memory-rater-e2e.test.ts +2 -3
  208. package/src/tests/migration-046-budgets.test.ts +2 -0
  209. package/src/tests/model-control.test.ts +2 -2
  210. package/src/tests/onboard-compose.test.ts +14 -0
  211. package/src/tests/onboard-env.test.ts +17 -0
  212. package/src/tests/onboard-manifest.test.ts +12 -0
  213. package/src/tests/opus-5-5-registry.test.ts +48 -0
  214. package/src/tests/pricing-refresh.test.ts +1 -1
  215. package/src/tests/pricing-routes.test.ts +15 -3
  216. package/src/tests/provider-registration.test.ts +5 -2
  217. package/src/tests/rbac-admission-e2e.test.ts +54 -0
  218. package/src/tests/rbac-e2e-helpers.ts +2 -0
  219. package/src/tests/rbac-engine.test.ts +32 -0
  220. package/src/tests/rbac-wire-e2e.test.ts +33 -0
  221. package/src/tests/reasoning-effort.test.ts +8 -0
  222. package/src/tests/reload-config.test.ts +33 -0
  223. package/src/tests/script-connections.test.ts +3 -0
  224. package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
  225. package/src/tests/slack-config.test.ts +136 -0
  226. package/src/tests/slack-mode-lifecycle.test.ts +69 -0
  227. package/src/tests/slack-render-v2.test.ts +446 -16
  228. package/src/tests/slack-steering.test.ts +69 -1
  229. package/src/tests/slack-task-output.test.ts +33 -12
  230. package/src/tests/status.test.ts +22 -2
  231. package/src/tests/steering-core.test.ts +35 -0
  232. package/src/tests/steering-delivery.test.ts +39 -0
  233. package/src/tests/steering-transport.test.ts +2 -2
  234. package/src/tests/task-tool-preload-mcp.test.ts +3 -1
  235. package/src/tests/telemetry-init.test.ts +18 -0
  236. package/src/tests/workflow-live-recovery.test.ts +126 -0
  237. package/src/tests/workflow-llm.test.ts +3 -3
  238. package/src/tools/defer-task.ts +64 -26
  239. package/src/tools/extension-common.ts +4 -0
  240. package/src/tools/extension-install.ts +1 -1
  241. package/src/tools/extension-list.ts +4 -3
  242. package/src/tools/join-swarm.ts +1 -1
  243. package/src/tools/schedules/create-schedule.ts +11 -1
  244. package/src/tools/schedules/patch-schedule.ts +5 -0
  245. package/src/tools/schedules/update-schedule.ts +5 -0
  246. package/src/tools/send-task.ts +6 -0
  247. package/src/tools/utils.ts +1 -1
  248. package/src/types.ts +30 -8
  249. package/src/utils/context-window.ts +1 -0
  250. package/src/utils/credentials.ts +8 -0
  251. package/src/utils/internal-ai/complete-structured.ts +8 -0
  252. package/src/utils/internal-ai/models.ts +5 -5
  253. package/src/utils/memory-raters.ts +3 -0
  254. package/src/workflows/engine.ts +28 -0
  255. package/src/workflows/recovery.ts +7 -4
  256. package/templates/schedules/daily-status-report/content.md +25 -12
  257. package/templates/schedules/daily-workflow-health-audit/content.md +1 -1
  258. package/templates/skills/swarm-extensions/content.md +9 -5
  259. package/dist/cli-4wppfkcm.js +0 -16384
  260. package/dist/cli-55qrx3q8.js +0 -6041
  261. package/dist/cli-cg4vnyh4.js +0 -305
  262. package/dist/cli-jyetr64j.js +0 -52
  263. package/dist/cli-pp9ax9pa.js +0 -520
  264. package/dist/cli-qa66qaf0.js +0 -6076
  265. package/dist/cli-qghpvca2.js +0 -590
  266. package/dist/cli-rembwgt8.js +0 -861
  267. package/dist/cli-tcvj7j2r.js +0 -1971
  268. package/dist/cli-vfcth11k.js +0 -36
  269. package/dist/cli-w1d1bgkk.js +0 -1422
  270. package/dist/cli-y6x0gqtg.js +0 -5977
  271. package/dist/es5-tj3tn4jj.js +0 -844
  272. package/dist/index-1kq5hw2p.js +0 -467
  273. package/dist/index-1p8wdstt.js +0 -7
  274. package/dist/index-8fdjzb2f.js +0 -100
  275. package/dist/index-bxmwzbgw.js +0 -365
  276. package/dist/index-nzerxd5z.js +0 -7
  277. package/dist/index-pyymnzat.js +0 -1168
  278. package/dist/index-qv6atbab.js +0 -619
  279. package/dist/index-rja32qa7.js +0 -801
  280. package/dist/index-tzg5srfp.js +0 -211
  281. package/dist/index-vhncw983.js +0 -90
  282. package/dist/index-xk036zsk.js +0 -859
  283. package/dist/index-zyazbzcb.js +0 -6
  284. package/dist/{claude-managed-setup-48dwg9bv.js → claude-managed-setup-f5pmsyrp.js} +3 -3
@@ -39,6 +39,7 @@ import {
39
39
  processSlackRenderV2,
40
40
  renderThreadTree,
41
41
  streamOutcomeCard,
42
+ withStatusLead,
42
43
  } from "../slack/render-v2";
43
44
  import { getAgentDisplayName, getAgentEmoji } from "../slack/responses";
44
45
  import { slackContextKey } from "../tasks/context-key";
@@ -54,6 +55,9 @@ let permalinkFailuresRemaining = 0;
54
55
  let slackAddressSequence = 0;
55
56
  let missingMessageTs: string | undefined;
56
57
  let updateFailuresRemaining = 0;
58
+ // A `chat.update` that answers with a Slack verdict other than 404 — the
59
+ // permanent, non-retryable class (`cant_update_message`, `channel_not_found`).
60
+ let rejectedUpdateTs: string | undefined;
57
61
  let disableRenderAfterMethod: string | undefined;
58
62
 
59
63
  type RemoteMessage = {
@@ -207,6 +211,7 @@ const mockApiCall = mock(async (method: string, payload: Record<string, unknown>
207
211
  throw new Error("temporary update failure");
208
212
  }
209
213
  if (missingMessageTs === payload.ts) throw { data: { error: "message_not_found" } };
214
+ if (rejectedUpdateTs === payload.ts) throw { data: { error: "cant_update_message" } };
210
215
  const message = remoteMessages.get(remoteKey(String(payload.channel), String(payload.ts)));
211
216
  if (!message) throw { data: { error: "message_not_found" } };
212
217
  const barrier = nextUpdateBarrier;
@@ -263,6 +268,7 @@ beforeEach(async () => {
263
268
  permalinkFailuresRemaining = 0;
264
269
  missingMessageTs = undefined;
265
270
  updateFailuresRemaining = 0;
271
+ rejectedUpdateTs = undefined;
266
272
  disableRenderAfterMethod = undefined;
267
273
  nextUpdateBarrier = undefined;
268
274
  _resetSlackRenderV2ForTests();
@@ -352,7 +358,7 @@ describe("Slack renderer v2", () => {
352
358
  expect(streams[0]?.payload).toMatchObject({
353
359
  channel: channelId,
354
360
  thread_ts: threadTs,
355
- markdown_text: "✅\n\nHere is the complete answer.",
361
+ markdown_text: "✅ Here is the complete answer.",
356
362
  });
357
363
  });
358
364
  }
@@ -883,6 +889,41 @@ describe("Slack renderer v2", () => {
883
889
  expect(doneText.startsWith("🧵 ✅ done")).toBe(true);
884
890
  });
885
891
 
892
+ test("a parked deferral reads waiting, and done once its wake-up settles", async () => {
893
+ const lead = await createAgent({ name: "Parked Header Lead", isLead: true, status: "idle" });
894
+ const ask = await createTaskExtended("parked header ask", {
895
+ agentId: lead.id,
896
+ source: "slack",
897
+ });
898
+ const now = new Date();
899
+ const parked = {
900
+ ...ask,
901
+ status: "completed" as const,
902
+ deferredAt: now.toISOString(),
903
+ lastUpdatedAt: now.toISOString(),
904
+ finishedAt: now.toISOString(),
905
+ };
906
+
907
+ // Stored `completed`, but nothing has answered the ask yet.
908
+ const waitingText = await renderThreadTree([parked], new Map(), now);
909
+ expect(waitingText.startsWith("🧵 ⏳ waiting")).toBe(true);
910
+ expect(waitingText).toContain("↳ ⏳ parked header ask");
911
+
912
+ const wake = {
913
+ ...parked,
914
+ id: crypto.randomUUID(),
915
+ source: "schedule" as const,
916
+ taskType: "deferred",
917
+ parentTaskId: ask.id,
918
+ deferredAt: undefined,
919
+ };
920
+ const doneText = await renderThreadTree([parked, wake], new Map(), now);
921
+ expect(doneText.startsWith("🧵 ✅ done")).toBe(true);
922
+ // The ask's own line settles with its wake-up, not only the header.
923
+ expect(doneText).not.toContain("⏳");
924
+ expect(doneText).toContain("↳ ✅ parked header ask");
925
+ });
926
+
886
927
  test("does not resolve or render direct-trigger permalink backlinks", async () => {
887
928
  const lead = await createAgent({ name: "Trigger Lead", isLead: true, status: "idle" });
888
929
  const worker = await createAgent({ name: "Trigger Worker", isLead: false, status: "idle" });
@@ -1145,7 +1186,7 @@ describe("Slack renderer v2", () => {
1145
1186
  const outcomeBody = outcomeChunks.join("");
1146
1187
  expect(outcomeChunks).toHaveLength(1);
1147
1188
  expect(outcomeBody).toBe(
1148
- "✅\n\nImplemented the Slack renderer and opened a focused pull request.\n\nSecond paragraph that must be rendered.",
1189
+ "✅ Implemented the Slack renderer and opened a focused pull request.\n\nSecond paragraph that must be rendered.",
1149
1190
  );
1150
1191
  expect(outcomeBody).not.toMatch(/\n{3,}/);
1151
1192
  expect(outcomeBody).toBe(outcomeBody.trim());
@@ -1155,7 +1196,7 @@ describe("Slack renderer v2", () => {
1155
1196
  expect(started.payload.thread_ts).toBe(threadTs);
1156
1197
  expect(started.payload.recipient_user_id).toBe("U_REQUESTER");
1157
1198
  expect(started.payload.recipient_team_id).toBe("T_TEST");
1158
- expect(String(started.payload.markdown_text).startsWith("✅\n\nImplemented")).toBe(true);
1199
+ expect(String(started.payload.markdown_text).startsWith("✅ Implemented")).toBe(true);
1159
1200
  expect(Object.keys(started.payload).sort()).toEqual([
1160
1201
  "channel",
1161
1202
  "icon_emoji",
@@ -1339,7 +1380,7 @@ describe("Slack renderer v2", () => {
1339
1380
  await processSlackRenderV2();
1340
1381
 
1341
1382
  const started = calls.find((call) => call.method === "chat.startStream");
1342
- expect(started?.payload.markdown_text).toBe(`✅\n\nDone${expected ? `\n\n${expected}` : ""}`);
1383
+ expect(started?.payload.markdown_text).toBe(`✅ Done${expected ? `\n\n${expected}` : ""}`);
1343
1384
  } finally {
1344
1385
  if (originalHost === undefined) delete process.env.AGENT_FS_LIVE_URL;
1345
1386
  else process.env.AGENT_FS_LIVE_URL = originalHost;
@@ -1417,10 +1458,10 @@ describe("Slack renderer v2", () => {
1417
1458
  await processSlackRenderV2();
1418
1459
 
1419
1460
  const started = calls.find((call) => call.method === "chat.startStream");
1420
- expect(started?.payload.markdown_text).toContain("❌ **Failed**");
1461
+ expect(started?.payload.markdown_text).toContain("❌ **Failed:**");
1421
1462
  const outcome = await getSlackOutcomeMessage(ask.id);
1422
1463
  const remote = remoteMessages.get(remoteKey(channelId, outcome!.ts));
1423
- expect(remote?.text).toBe(`❌ **Failed**\n\n${reason.trim()}`);
1464
+ expect(remote?.text).toBe(`❌ **Failed:** ${reason.trim()}`);
1424
1465
  expect(remote?.text).not.toContain(getTaskLink(ask.id));
1425
1466
  expect(calls.some((call) => call.method === "chat.appendStream")).toBe(false);
1426
1467
  const update = calls.find(
@@ -1450,11 +1491,11 @@ describe("Slack renderer v2", () => {
1450
1491
  await processSlackRenderV2();
1451
1492
 
1452
1493
  const started = calls.find((call) => call.method === "chat.startStream");
1453
- expect(started?.payload.markdown_text).toContain("🚫 **Cancelled**");
1494
+ expect(started?.payload.markdown_text).toContain("🚫 **Cancelled:**");
1454
1495
  const outcome = (await getSlackOutcomeMessage(ask.id))!;
1455
1496
  const remote = remoteMessages.get(remoteKey(channelId, outcome.ts));
1456
1497
  expect(remote?.text).toBe(
1457
- `🚫 **Cancelled**\n\nrequester changed direction ${"context ".repeat(200)}`.trim(),
1498
+ `🚫 **Cancelled:** requester changed direction ${"context ".repeat(200)}`.trim(),
1458
1499
  );
1459
1500
  expect(remote?.text).not.toContain(getTaskLink(ask.id));
1460
1501
  expect(calls.some((call) => call.method === "chat.appendStream")).toBe(false);
@@ -1719,7 +1760,7 @@ describe("Slack renderer v2", () => {
1719
1760
 
1720
1761
  const started = calls.find((call) => call.method === "chat.startStream");
1721
1762
  expect(started?.payload.markdown_text).toBe(
1722
- "✅\n\nThis output must reach Slack since no slack-reply was sent.",
1763
+ "✅ This output must reach Slack since no slack-reply was sent.",
1723
1764
  );
1724
1765
  });
1725
1766
 
@@ -1758,7 +1799,7 @@ describe("Slack renderer v2", () => {
1758
1799
 
1759
1800
  const started = calls.find((call) => call.method === "chat.startStream");
1760
1801
  expect(started?.payload.markdown_text).toBe(
1761
- `✅\n\nArtifact: https://example.test/downloads/${redacted}/result.json`,
1802
+ `✅ Artifact: https://example.test/downloads/${redacted}/result.json`,
1762
1803
  );
1763
1804
  expect(started?.payload.markdown_text).not.toContain(secret);
1764
1805
  });
@@ -1789,7 +1830,7 @@ describe("Slack renderer v2", () => {
1789
1830
  expect(outcome?.finalizedAt).toBeDefined();
1790
1831
  });
1791
1832
 
1792
- test("omits the deferral ETA even when the agent already sent a slack-reply", async () => {
1833
+ test("renders a legacy deferral as ⏳ with its ETA, even after a slack-reply", async () => {
1793
1834
  const lead = await createAgent({ name: "Deferring Lead", isLead: true, status: "idle" });
1794
1835
  const { channelId, threadTs } = uniqueSlackAddress("C_RENDER_DEFER_AFTER_REPLY");
1795
1836
  const ask = await createTaskExtended("defer after replying by hand", {
@@ -1814,14 +1855,362 @@ describe("Slack renderer v2", () => {
1814
1855
  await processSlackRenderV2();
1815
1856
 
1816
1857
  const started = calls.find((call) => call.method === "chat.startStream");
1817
- expect(started?.payload.markdown_text).toBe("✅\n\nPending: checking the new defer card");
1818
- expect(JSON.stringify(calls)).not.toContain("17:30:19");
1858
+ // ⏳ not ✅: the ask was parked, not answered. The ETA is what a human
1859
+ // needs; the agent's internal note and the schedule link are dropped.
1860
+ expect(started?.payload.markdown_text).toBe("⏳ Checking back today 17:30:19 UTC");
1861
+ expect(JSON.stringify(calls)).not.toContain("checking the new defer card");
1862
+ expect(JSON.stringify(calls)).not.toContain("715bf847-fe3e");
1819
1863
  expect(JSON.stringify(calls)).not.toContain("Deferred until");
1820
1864
  expect((await getTaskById(ask.id))?.output).toBe(deferralLine);
1821
1865
  expect(started?.payload.markdown_text).not.toBe(`✅ ${lead.name} completed`);
1822
1866
  });
1823
1867
 
1824
- test("does not post an ETA-only deferral notice", async () => {
1868
+ test("a deferral card opens with ⏳ and closes in place when the wake-up settles", async () => {
1869
+ const lead = await createAgent({ name: "Parking Lead", isLead: true, status: "idle" });
1870
+ const { channelId, threadTs } = uniqueSlackAddress("C_DEFER_RESOLVE");
1871
+ const ask = await createTaskExtended("answer when the build is done", {
1872
+ agentId: lead.id,
1873
+ source: "slack",
1874
+ slackChannelId: channelId,
1875
+ slackThreadTs: threadTs,
1876
+ contextKey: slackContextKey({ channelId, threadTs }),
1877
+ });
1878
+ await startTask(ask.id);
1879
+ // Mirrors defer-task.ts: the human card as stored output, plus the marker
1880
+ // that tells "parked" from "done" on a row whose status is `completed`.
1881
+ await completeTask(ask.id, "Checking back today at 18:38", {
1882
+ addTags: ["deferred"],
1883
+ deferredAt: new Date().toISOString(),
1884
+ });
1885
+ await backdateLastUpdated([ask.id], 20);
1886
+ calls.length = 0;
1887
+ await processSlackRenderV2();
1888
+
1889
+ const opening = calls.find((call) => call.method === "chat.startStream");
1890
+ expect(opening?.payload.markdown_text).toBe("⏳ Checking back today at 18:38");
1891
+ const card = await getSlackOutcomeMessage(ask.id);
1892
+ expect(card?.finalizedAt).toBeTruthy();
1893
+ expect(card?.deferralResolvedAt).toBeUndefined();
1894
+
1895
+ // The wake-up task runs and finishes.
1896
+ const schedule = await createScheduledTask({
1897
+ name: "deferred-resolve",
1898
+ scheduleType: "one_time",
1899
+ taskTemplate: "Deliver the result",
1900
+ taskType: "deferred",
1901
+ tags: ["deferred"],
1902
+ parentTaskId: ask.id,
1903
+ targetAgentId: lead.id,
1904
+ createdByAgentId: lead.id,
1905
+ nextRunAt: new Date().toISOString(),
1906
+ });
1907
+ const wake = await createStandaloneScheduleTask(schedule);
1908
+ await startTask(wake.id);
1909
+ await completeTask(wake.id, "The build passed.");
1910
+ calls.length = 0;
1911
+ await processSlackRenderV2();
1912
+
1913
+ // The SAME message is rewritten — no new opening card for the ask.
1914
+ const rewrite = calls.find(
1915
+ (call) => call.method === "chat.update" && call.payload.ts === card?.ts,
1916
+ );
1917
+ expect(rewrite).toBeDefined();
1918
+ expect(rewrite?.payload.text).toBe("✅ The build passed.");
1919
+ expect(rewrite?.payload.text).not.toContain("Checking back");
1920
+ expect((await getSlackOutcomeMessage(ask.id))?.deferralResolvedAt).toBeTruthy();
1921
+ // The rewrite IS the wake-up's answer: no second card repeating it.
1922
+ expect(calls.filter((call) => call.method === "chat.startStream")).toHaveLength(0);
1923
+ expect(await getSlackOutcomeMessage(wake.id)).toBeNull();
1924
+
1925
+ // Idempotent: a second tick must not rewrite it again.
1926
+ calls.length = 0;
1927
+ await processSlackRenderV2();
1928
+ expect(
1929
+ calls.filter((call) => call.method === "chat.update" && call.payload.ts === card?.ts),
1930
+ ).toHaveLength(0);
1931
+ });
1932
+
1933
+ test("a parked ask keeps 👀 and settles to ✅ only when its wake-up answers", async () => {
1934
+ const lead = await createAgent({ name: "Parked Reaction Lead", isLead: true, status: "idle" });
1935
+ const { channelId, threadTs } = uniqueSlackAddress("C_DEFER_REACTION");
1936
+ const triggerTs = `${slackAddressSequence}.2`;
1937
+ const ask = await createTaskExtended("answer when the build is done", {
1938
+ agentId: lead.id,
1939
+ source: "slack",
1940
+ slackChannelId: channelId,
1941
+ slackThreadTs: threadTs,
1942
+ slackTriggerMessageTs: triggerTs,
1943
+ contextKey: slackContextKey({ channelId, threadTs }),
1944
+ });
1945
+ await startTask(ask.id);
1946
+ await completeTask(ask.id, "Checking back today at 18:38", {
1947
+ addTags: ["deferred"],
1948
+ deferredAt: new Date().toISOString(),
1949
+ });
1950
+ await backdateLastUpdated([ask.id], 20);
1951
+ calls.length = 0;
1952
+ await processSlackRenderV2();
1953
+ await new Promise((resolve) => setTimeout(resolve, 0));
1954
+ const doneReaction = {
1955
+ method: "reactions.add",
1956
+ payload: { channel: channelId, name: "white_check_mark", timestamp: triggerTs },
1957
+ };
1958
+ expect(calls).not.toContainEqual(doneReaction);
1959
+
1960
+ const schedule = await createScheduledTask({
1961
+ name: "deferred-reaction",
1962
+ scheduleType: "one_time",
1963
+ taskTemplate: "Deliver the result",
1964
+ taskType: "deferred",
1965
+ tags: ["deferred"],
1966
+ parentTaskId: ask.id,
1967
+ targetAgentId: lead.id,
1968
+ createdByAgentId: lead.id,
1969
+ nextRunAt: new Date().toISOString(),
1970
+ });
1971
+ const wake = await createStandaloneScheduleTask(schedule);
1972
+ // The wake-up does not inherit the trigger ts; the reaction path walks back.
1973
+ expect(wake.slackTriggerMessageTs).toBeUndefined();
1974
+ await startTask(wake.id);
1975
+ await completeTask(wake.id, "The build passed.");
1976
+ calls.length = 0;
1977
+ await processSlackRenderV2();
1978
+ await new Promise((resolve) => setTimeout(resolve, 0));
1979
+ expect(calls).toContainEqual(doneReaction);
1980
+ });
1981
+
1982
+ test("a wake-up that deferred again posts the next ⏳ card, and the old card points at it", async () => {
1983
+ const lead = await createAgent({ name: "Chained Lead", isLead: true, status: "idle" });
1984
+ const { channelId, threadTs } = uniqueSlackAddress("C_DEFER_CHAIN");
1985
+ const ask = await createTaskExtended("answer when the build is done", {
1986
+ agentId: lead.id,
1987
+ source: "slack",
1988
+ slackChannelId: channelId,
1989
+ slackThreadTs: threadTs,
1990
+ contextKey: slackContextKey({ channelId, threadTs }),
1991
+ });
1992
+ await startTask(ask.id);
1993
+ await completeTask(ask.id, "Checking back today at 18:38", {
1994
+ addTags: ["deferred"],
1995
+ deferredAt: new Date().toISOString(),
1996
+ });
1997
+ await backdateLastUpdated([ask.id], 20);
1998
+ await processSlackRenderV2();
1999
+ const card = await getSlackOutcomeMessage(ask.id);
2000
+
2001
+ const schedule = await createScheduledTask({
2002
+ name: "deferred-chain",
2003
+ scheduleType: "one_time",
2004
+ taskTemplate: "Deliver the result",
2005
+ taskType: "deferred",
2006
+ tags: ["deferred"],
2007
+ parentTaskId: ask.id,
2008
+ targetAgentId: lead.id,
2009
+ createdByAgentId: lead.id,
2010
+ nextRunAt: new Date().toISOString(),
2011
+ });
2012
+ const wake = await createStandaloneScheduleTask(schedule);
2013
+ await startTask(wake.id);
2014
+ await completeTask(wake.id, "Checking back today at 20:00", {
2015
+ addTags: ["deferred"],
2016
+ deferredAt: new Date().toISOString(),
2017
+ });
2018
+ calls.length = 0;
2019
+ await processSlackRenderV2();
2020
+
2021
+ // The chain continues on a new ⏳ card, which the next wake-up resolves.
2022
+ const next = calls.find((call) => call.method === "chat.startStream");
2023
+ expect(next?.payload.markdown_text).toBe("⏳ Checking back today at 20:00");
2024
+ const nextCard = await getSlackOutcomeMessage(wake.id);
2025
+ expect(nextCard?.permalink).toBeTruthy();
2026
+ // The old card hands over to it rather than repeating the new ETA.
2027
+ const rewrite = calls.find(
2028
+ (call) => call.method === "chat.update" && call.payload.ts === card?.ts,
2029
+ );
2030
+ expect(rewrite?.payload.text).toBe(
2031
+ `↪️ Resumed by ${lead.name} and deferred again — ${nextCard?.permalink}`,
2032
+ );
2033
+ });
2034
+
2035
+ test("a wake-up that failed closes the deferral card as failed, not as done", async () => {
2036
+ const lead = await createAgent({ name: "Failing Wake Lead", isLead: true, status: "idle" });
2037
+ const { channelId, threadTs } = uniqueSlackAddress("C_DEFER_RESOLVE_FAIL");
2038
+ const ask = await createTaskExtended("answer later", {
2039
+ agentId: lead.id,
2040
+ source: "slack",
2041
+ slackChannelId: channelId,
2042
+ slackThreadTs: threadTs,
2043
+ contextKey: slackContextKey({ channelId, threadTs }),
2044
+ });
2045
+ await startTask(ask.id);
2046
+ await completeTask(ask.id, "Waiting on Researcher — or today at 18:38 at the latest", {
2047
+ addTags: ["deferred"],
2048
+ deferredAt: new Date().toISOString(),
2049
+ });
2050
+ await backdateLastUpdated([ask.id], 20);
2051
+ await processSlackRenderV2();
2052
+ const card = await getSlackOutcomeMessage(ask.id);
2053
+
2054
+ const schedule = await createScheduledTask({
2055
+ name: "deferred-resolve-fail",
2056
+ scheduleType: "one_time",
2057
+ taskTemplate: "Deliver the result",
2058
+ taskType: "deferred",
2059
+ tags: ["deferred"],
2060
+ parentTaskId: ask.id,
2061
+ targetAgentId: lead.id,
2062
+ createdByAgentId: lead.id,
2063
+ nextRunAt: new Date().toISOString(),
2064
+ });
2065
+ const wake = await createStandaloneScheduleTask(schedule);
2066
+ await startTask(wake.id);
2067
+ await failTask(wake.id, "the build never finished");
2068
+ calls.length = 0;
2069
+ await processSlackRenderV2();
2070
+
2071
+ const rewrite = calls.find(
2072
+ (call) => call.method === "chat.update" && call.payload.ts === card?.ts,
2073
+ );
2074
+ expect(rewrite?.payload.text).toContain("❌");
2075
+ expect(rewrite?.payload.text).toContain("the build never finished");
2076
+ expect(rewrite?.payload.text).not.toContain("Waiting on Researcher");
2077
+ expect(calls.filter((call) => call.method === "chat.startStream")).toHaveLength(0);
2078
+ });
2079
+
2080
+ test("a deferral whose wake-up is still running keeps its ⏳ card untouched", async () => {
2081
+ const lead = await createAgent({ name: "Pending Wake Lead", isLead: true, status: "idle" });
2082
+ const { channelId, threadTs } = uniqueSlackAddress("C_DEFER_STILL_WAITING");
2083
+ const ask = await createTaskExtended("answer eventually", {
2084
+ agentId: lead.id,
2085
+ source: "slack",
2086
+ slackChannelId: channelId,
2087
+ slackThreadTs: threadTs,
2088
+ contextKey: slackContextKey({ channelId, threadTs }),
2089
+ });
2090
+ await startTask(ask.id);
2091
+ await completeTask(ask.id, "Checking back today at 18:38", {
2092
+ addTags: ["deferred"],
2093
+ deferredAt: new Date().toISOString(),
2094
+ });
2095
+ await backdateLastUpdated([ask.id], 20);
2096
+ await processSlackRenderV2();
2097
+ const card = await getSlackOutcomeMessage(ask.id);
2098
+
2099
+ const schedule = await createScheduledTask({
2100
+ name: "deferred-still-running",
2101
+ scheduleType: "one_time",
2102
+ taskTemplate: "Deliver the result",
2103
+ taskType: "deferred",
2104
+ tags: ["deferred"],
2105
+ parentTaskId: ask.id,
2106
+ targetAgentId: lead.id,
2107
+ createdByAgentId: lead.id,
2108
+ nextRunAt: new Date().toISOString(),
2109
+ });
2110
+ const wake = await createStandaloneScheduleTask(schedule);
2111
+ await startTask(wake.id);
2112
+ calls.length = 0;
2113
+ await processSlackRenderV2();
2114
+
2115
+ expect(
2116
+ calls.filter((call) => call.method === "chat.update" && call.payload.ts === card?.ts),
2117
+ ).toHaveLength(0);
2118
+ expect((await getSlackOutcomeMessage(ask.id))?.deferralResolvedAt).toBeUndefined();
2119
+ });
2120
+
2121
+ /**
2122
+ * Sets up a deferral card whose wake-up has already settled, so the next
2123
+ * tick is the one that tries to rewrite the card in place.
2124
+ */
2125
+ async function resolvableDeferralCard(label: string) {
2126
+ const lead = await createAgent({ name: `${label} Lead`, isLead: true, status: "idle" });
2127
+ const { channelId, threadTs } = uniqueSlackAddress(label);
2128
+ const ask = await createTaskExtended("answer when the build is done", {
2129
+ agentId: lead.id,
2130
+ source: "slack",
2131
+ slackChannelId: channelId,
2132
+ slackThreadTs: threadTs,
2133
+ contextKey: slackContextKey({ channelId, threadTs }),
2134
+ });
2135
+ await startTask(ask.id);
2136
+ await completeTask(ask.id, "Checking back today at 18:38", {
2137
+ addTags: ["deferred"],
2138
+ deferredAt: new Date().toISOString(),
2139
+ });
2140
+ await backdateLastUpdated([ask.id], 20);
2141
+ await processSlackRenderV2();
2142
+ const card = await getSlackOutcomeMessage(ask.id);
2143
+
2144
+ const schedule = await createScheduledTask({
2145
+ name: `${label}-resolve`,
2146
+ scheduleType: "one_time",
2147
+ taskTemplate: "Deliver the result",
2148
+ taskType: "deferred",
2149
+ tags: ["deferred"],
2150
+ parentTaskId: ask.id,
2151
+ targetAgentId: lead.id,
2152
+ createdByAgentId: lead.id,
2153
+ nextRunAt: new Date().toISOString(),
2154
+ });
2155
+ const wake = await createStandaloneScheduleTask(schedule);
2156
+ await startTask(wake.id);
2157
+ await completeTask(wake.id, "The build passed.");
2158
+ return { askId: ask.id, wakeId: wake.id, ts: card!.ts };
2159
+ }
2160
+
2161
+ test("a deferral card Slack refuses for good stops being retried", async () => {
2162
+ const card = await resolvableDeferralCard("C_DEFER_REJECTED");
2163
+ // Not a 404: the message is still there, Slack just will not accept the
2164
+ // rewrite. The old code logged this and left the row eligible, so the
2165
+ // same doomed chat.update ran on every single tick from then on.
2166
+ rejectedUpdateTs = card.ts;
2167
+ calls.length = 0;
2168
+ await processSlackRenderV2();
2169
+ expect(
2170
+ calls.filter((call) => call.method === "chat.update" && call.payload.ts === card.ts),
2171
+ ).toHaveLength(1);
2172
+ const abandoned = await getSlackOutcomeMessage(card.askId);
2173
+ expect(abandoned?.deferralResolvedAt).toBeTruthy();
2174
+ expect(abandoned?.deferralAbandonedAt).toBeTruthy();
2175
+
2176
+ calls.length = 0;
2177
+ await processSlackRenderV2();
2178
+ await processSlackRenderV2();
2179
+ expect(
2180
+ calls.filter((call) => call.method === "chat.update" && call.payload.ts === card.ts),
2181
+ ).toHaveLength(0);
2182
+ // The answer never reached the card, so the wake-up delivers it itself.
2183
+ const fallback = calls.filter((call) => call.method === "chat.startStream");
2184
+ expect(fallback).toHaveLength(1);
2185
+ expect(fallback[0]?.payload.markdown_text).toBe("✅ The build passed.");
2186
+ expect((await getSlackOutcomeMessage(card.wakeId))?.finalizedAt).toBeTruthy();
2187
+ });
2188
+
2189
+ test("a deferral card failing for an unclassifiable reason is retried, but bounded", async () => {
2190
+ const card = await resolvableDeferralCard("C_DEFER_BOUNDED");
2191
+ // No Slack verdict attached, so this class stays retryable — up to a
2192
+ // ceiling. Keep the failure count far above the ceiling to prove the
2193
+ // ceiling, not the mock, is what ends it.
2194
+ updateFailuresRemaining = 100;
2195
+ const attempts = () =>
2196
+ calls.filter((call) => call.method === "chat.update" && call.payload.ts === card.ts).length;
2197
+
2198
+ calls.length = 0;
2199
+ for (let tick = 0; tick < 4; tick++) await processSlackRenderV2();
2200
+ expect(attempts()).toBe(4);
2201
+ expect((await getSlackOutcomeMessage(card.askId))?.deferralResolvedAt).toBeUndefined();
2202
+
2203
+ await processSlackRenderV2();
2204
+ expect(attempts()).toBe(5);
2205
+ expect((await getSlackOutcomeMessage(card.askId))?.deferralResolvedAt).toBeTruthy();
2206
+
2207
+ calls.length = 0;
2208
+ await processSlackRenderV2();
2209
+ await processSlackRenderV2();
2210
+ expect(attempts()).toBe(0);
2211
+ });
2212
+
2213
+ test("a deferral with no pending text still posts its ETA", async () => {
1825
2214
  const { channelId, threadTs } = uniqueSlackAddress("C_ETA_ONLY");
1826
2215
  const ask = await createTaskExtended("wait", {
1827
2216
  source: "slack",
@@ -1836,8 +2225,10 @@ describe("Slack renderer v2", () => {
1836
2225
  );
1837
2226
  calls.length = 0;
1838
2227
  await processSlackRenderV2();
1839
- expect(calls.filter((call) => call.method === "chat.startStream")).toHaveLength(0);
1840
- expect(JSON.stringify(calls)).not.toContain("17:30");
2228
+ // The ETA IS the message now, so there is no such thing as an "ETA-only"
2229
+ // notice worth suppressing — a thread that says nothing is the bug.
2230
+ const started = calls.find((call) => call.method === "chat.startStream");
2231
+ expect(started?.payload.markdown_text).toBe("⏳ Checking back today 17:30 UTC");
1841
2232
  });
1842
2233
 
1843
2234
  test("refreshes a stream started with stale content before finalizing it", async () => {
@@ -2869,3 +3260,42 @@ describe("Slack renderer v2 delegation (SLACK_RENDER_V2_DELEGATION)", () => {
2869
3260
  }
2870
3261
  });
2871
3262
  });
3263
+
3264
+ describe("withStatusLead", () => {
3265
+ test.each([
3266
+ ["✅", "Hybrid and graph already match.", "✅ Hybrid and graph already match."],
3267
+ ["⏳", "Checking back at 18:00.", "⏳ Checking back at 18:00."],
3268
+ ["❌ **Failed:**", "build broke", "❌ **Failed:** build broke"],
3269
+ [
3270
+ "🚫 **Cancelled:**",
3271
+ "requester changed direction",
3272
+ "🚫 **Cancelled:** requester changed direction",
3273
+ ],
3274
+ [
3275
+ "↳ ✅ Worker — result:",
3276
+ "Opened PR.\n\nDetails.",
3277
+ "↳ ✅ Worker — result: Opened PR.\n\nDetails.",
3278
+ ],
3279
+ ["✅", "\n\n Leading blank lines drop.", "✅ Leading blank lines drop."],
3280
+ ["✅", "**Bold** opener stays inline.", "✅ **Bold** opener stays inline."],
3281
+ ["✅", "1.5x faster than before.", "✅ 1.5x faster than before."],
3282
+ ])("inlines %p with a plain opener", (lead, body, expected) => {
3283
+ expect(withStatusLead(lead, body)).toBe(expected);
3284
+ });
3285
+
3286
+ test.each([
3287
+ ["code fence", "```ts\nconst x = 1;\n```"],
3288
+ ["tilde fence", "~~~\nraw\n~~~"],
3289
+ ["dash list", "- first\n- second"],
3290
+ ["star list", "* first"],
3291
+ ["ordered list", "1. first\n2. second"],
3292
+ ["blockquote", "> quoted"],
3293
+ ["heading", "# Title\n\nBody"],
3294
+ ["table", "| a | b |\n| - | - |"],
3295
+ ["thematic break", "---\nafter"],
3296
+ ["indented code", " indented"],
3297
+ ])("keeps the blank line before a %s", (_name, body) => {
3298
+ expect(withStatusLead("✅", body)).toBe(`✅\n\n${body}`);
3299
+ expect(withStatusLead("❌ **Failed:**", body)).toBe(`❌ **Failed:**\n\n${body}`);
3300
+ });
3301
+ });