@vellumai/assistant 0.11.8 → 0.11.9-staging.2

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 (326) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/Dockerfile +8 -48
  3. package/docker-entrypoint.sh +5 -1
  4. package/docker-kata-apt-env.sh +3 -0
  5. package/docker-kata-apt-shims.sh +127 -0
  6. package/docker-kata-apt-wrapper.sh +45 -0
  7. package/docker-kata-chroot-exec.sh +35 -0
  8. package/docker-kata-pip.sh +8 -2
  9. package/docs/architecture/memory.md +8 -4
  10. package/docs/guardian-request-flow.md +18 -14
  11. package/docs/trusted-contact-access.md +1 -7
  12. package/node_modules/@vellumai/avatar-catalog/src/catalog.ts +7 -1
  13. package/node_modules/@vellumai/avatar-catalog/src/index.ts +1 -1
  14. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +4 -1
  15. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  16. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  17. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  18. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  19. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +4 -1
  20. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  21. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  22. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  23. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  24. package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +0 -18
  25. package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +110 -5
  26. package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +5 -33
  27. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +3 -0
  28. package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +100 -9
  29. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -2
  30. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +9 -0
  31. package/node_modules/@vellumai/service-contracts/package.json +4 -1
  32. package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  33. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  34. package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  35. package/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  36. package/openapi.yaml +193 -4
  37. package/package.json +2 -2
  38. package/src/__tests__/access-request-card-view.test.ts +6 -5
  39. package/src/__tests__/access-request-seed-content-blocks.test.ts +5 -2
  40. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +43 -82
  41. package/src/__tests__/always-loaded-tools-guard.test.ts +8 -2
  42. package/src/__tests__/attachment-stored-path-annotation.test.ts +80 -0
  43. package/src/__tests__/attachments-store.test.ts +27 -0
  44. package/src/__tests__/attachments.test.ts +43 -0
  45. package/src/__tests__/canned-reply-release.test.ts +48 -5
  46. package/src/__tests__/channel-delivery-store.test.ts +129 -18
  47. package/src/__tests__/channel-reply-delivery.test.ts +553 -93
  48. package/src/__tests__/channel-retry-sweep.test.ts +199 -0
  49. package/src/__tests__/client-os-metadata-persistence.test.ts +32 -3
  50. package/src/__tests__/conversation-error.test.ts +15 -0
  51. package/src/__tests__/conversation-load-history-repair.test.ts +118 -0
  52. package/src/__tests__/conversation-pairing.test.ts +141 -1
  53. package/src/__tests__/conversation-routes-disk-view.test.ts +28 -1
  54. package/src/__tests__/conversation-routes-slash-commands.test.ts +125 -0
  55. package/src/__tests__/conversation-runtime-assembly.test.ts +79 -0
  56. package/src/__tests__/conversation-store-ephemeral.test.ts +323 -10
  57. package/src/__tests__/conversation-sync-tags.test.ts +2 -37
  58. package/src/__tests__/credential-health-service.test.ts +98 -10
  59. package/src/__tests__/delete-propagation.test.ts +469 -0
  60. package/src/__tests__/dm-persistence.test.ts +16 -0
  61. package/src/__tests__/docker-kata-apt-shims.test.ts +135 -0
  62. package/src/__tests__/forbidden-legacy-symbols.test.ts +12 -0
  63. package/src/__tests__/gemini-provider.test.ts +46 -0
  64. package/src/__tests__/guardian-card-withdrawal.test.ts +0 -22
  65. package/src/__tests__/guardian-gateway-sim.ts +0 -23
  66. package/src/__tests__/guardian-question-mode.test.ts +108 -0
  67. package/src/__tests__/guardian-reply-router-answer-mode.test.ts +0 -2
  68. package/src/__tests__/guardian-routing-invariants.test.ts +26 -12
  69. package/src/__tests__/host-proxy-interface.test.ts +10 -0
  70. package/src/__tests__/inbound-slack-persistence.test.ts +16 -0
  71. package/src/__tests__/injector-v3-suppression.test.ts +43 -9
  72. package/src/__tests__/list-messages-page-latest.test.ts +127 -0
  73. package/src/__tests__/list-messages-system-card.test.ts +103 -0
  74. package/src/__tests__/media-resolve-image-validation.test.ts +77 -0
  75. package/src/__tests__/notification-decision-fallback.test.ts +199 -77
  76. package/src/__tests__/notification-decision-strategy.test.ts +198 -213
  77. package/src/__tests__/notification-discord-adapter.test.ts +25 -0
  78. package/src/__tests__/notification-slack-adapter.test.ts +133 -0
  79. package/src/__tests__/notification-telegram-adapter.test.ts +36 -0
  80. package/src/__tests__/openai-provider.test.ts +5 -5
  81. package/src/__tests__/outbound-slack-persistence.test.ts +85 -72
  82. package/src/__tests__/persist-user-message-set-processing-failure.test.ts +83 -65
  83. package/src/__tests__/platform-client-verify-credential.test.ts +100 -0
  84. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -1
  85. package/src/__tests__/pricing.test.ts +11 -0
  86. package/src/__tests__/process-message-display-content.test.ts +20 -9
  87. package/src/__tests__/processing-acquire-fenced-guard.test.ts +56 -0
  88. package/src/__tests__/provider-meta-persistence.test.ts +67 -0
  89. package/src/__tests__/reaction-persistence.test.ts +22 -198
  90. package/src/__tests__/run-conversation-turn-persistence.test.ts +5 -2
  91. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +16 -0
  92. package/src/__tests__/skill-load-tool.test.ts +27 -0
  93. package/src/__tests__/skills.test.ts +34 -1
  94. package/src/__tests__/strip-memory-injections.test.ts +3 -4
  95. package/src/__tests__/terminal-tools.test.ts +9 -0
  96. package/src/__tests__/thread-backfill.test.ts +5 -3
  97. package/src/__tests__/unified-turn-context-location.test.ts +76 -0
  98. package/src/__tests__/voice-session-bridge.test.ts +18 -0
  99. package/src/__tests__/watch-retro-report-payload.test.ts +185 -0
  100. package/src/__tests__/watch-retro-tool-availability.test.ts +82 -0
  101. package/src/__tests__/workspace-migration-151-repair-renamed-fireworks-deepseek-pro-model-id.test.ts +235 -0
  102. package/src/__tests__/workspace-migration-152-repair-retired-fireworks-minimax-m2p7-model-id.test.ts +233 -0
  103. package/src/agent/attachments.ts +13 -2
  104. package/src/agent/loop.ts +3 -19
  105. package/src/api/README.md +9 -5
  106. package/src/api/index.ts +9 -8
  107. package/src/api/package.json +1 -0
  108. package/src/api/responses/conversation-message.ts +8 -0
  109. package/src/api/responses/home.ts +7 -18
  110. package/src/api/surfaces.ts +114 -7
  111. package/src/approvals/AGENTS.md +1 -1
  112. package/src/channels/__tests__/gateway-guardian-requests.test.ts +1 -23
  113. package/src/channels/__tests__/types.test.ts +22 -1
  114. package/src/channels/gateway-guardian-requests.ts +0 -22
  115. package/src/channels/types.ts +8 -6
  116. package/src/cli/__tests__/catalog-search-help.test.ts +18 -0
  117. package/src/cli/commands/channels/__tests__/channels.test.ts +26 -0
  118. package/src/cli/commands/channels/index.help.ts +19 -6
  119. package/src/cli/commands/channels/index.ts +6 -2
  120. package/src/cli/commands/db/__tests__/status.test.ts +22 -0
  121. package/src/cli/commands/db/index.help.ts +1 -1
  122. package/src/cli/commands/db/status.ts +172 -1
  123. package/src/cli/commands/platform/__tests__/connect.test.ts +29 -0
  124. package/src/cli/commands/platform/connect.ts +14 -5
  125. package/src/cli/commands/plugins.help.ts +6 -5
  126. package/src/cli/lib/__tests__/install-from-github.test.ts +0 -8
  127. package/src/cli/lib/__tests__/install-from-platform.test.ts +72 -0
  128. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +33 -0
  129. package/src/cli/lib/bundled-marketplace.json +14 -0
  130. package/src/cli/lib/install-from-github.ts +9 -5
  131. package/src/cli/lib/install-from-platform.ts +12 -1
  132. package/src/config/__tests__/assistant-initiated-threads-gate.test.ts +61 -0
  133. package/src/config/assistant-initiated-threads-gate.ts +50 -0
  134. package/src/config/bundled-skills/acp/SKILL.md +10 -3
  135. package/src/config/bundled-skills/schedule/SKILL.md +25 -11
  136. package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +3 -1
  137. package/src/config/call-site-defaults.ts +4 -1
  138. package/src/config/feature-flag-registry.json +21 -4
  139. package/src/config/schemas/memory-v3.ts +4 -3
  140. package/src/context/strip-injections.ts +17 -56
  141. package/src/conversations/__tests__/message-consolidation.test.ts +39 -0
  142. package/src/conversations/message-consolidation.ts +4 -3
  143. package/src/credential-health/credential-health-service.ts +130 -0
  144. package/src/daemon/__tests__/conversation-tool-setup.test.ts +31 -0
  145. package/src/daemon/conversation-agent-loop-handlers.ts +55 -59
  146. package/src/daemon/conversation-error.ts +2 -0
  147. package/src/daemon/conversation-messaging.ts +195 -47
  148. package/src/daemon/conversation-process.ts +20 -5
  149. package/src/daemon/conversation-runtime-assembly.ts +48 -39
  150. package/src/daemon/conversation-store.ts +159 -11
  151. package/src/daemon/conversation-tool-setup.ts +9 -2
  152. package/src/daemon/conversation.ts +290 -19
  153. package/src/daemon/dictation-text-processing.ts +2 -7
  154. package/src/daemon/handlers/config-channels.ts +33 -3
  155. package/src/daemon/handlers/config-model.test.ts +1 -0
  156. package/src/daemon/handlers/shared.ts +9 -0
  157. package/src/daemon/port-oversized-content.test.ts +117 -0
  158. package/src/daemon/port-oversized-content.ts +110 -0
  159. package/src/daemon/process-message.ts +87 -16
  160. package/src/daemon/reaction-record.test.ts +23 -11
  161. package/src/daemon/reaction-record.ts +6 -9
  162. package/src/documents/document-store.ts +1 -5
  163. package/src/home/conversation-starter-validation.ts +1 -5
  164. package/src/live-voice/__tests__/live-voice-photo.test.ts +910 -50
  165. package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +31 -25
  166. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +70 -1
  167. package/src/live-voice/live-voice-photo.ts +544 -61
  168. package/src/live-voice/live-voice-session.ts +6 -2
  169. package/src/live-voice/protocol.ts +20 -1
  170. package/src/messaging/provider-message-metadata.ts +40 -1
  171. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +10 -2
  172. package/src/messaging/providers/channel-transport.ts +28 -0
  173. package/src/messaging/providers/discord/send.ts +3 -2
  174. package/src/messaging/providers/slack/api.ts +11 -1
  175. package/src/messaging/providers/slack/message-metadata.test.ts +133 -0
  176. package/src/messaging/providers/slack/message-metadata.ts +141 -1
  177. package/src/messaging/providers/slack/send.test.ts +58 -0
  178. package/src/messaging/providers/slack/send.ts +4 -4
  179. package/src/messaging/providers/slack/transport.ts +28 -2
  180. package/src/messaging/providers/telegram-bot/send.test.ts +159 -0
  181. package/src/messaging/providers/telegram-bot/send.ts +93 -0
  182. package/src/messaging/providers/telegram-bot/transport.ts +71 -0
  183. package/src/messaging/reaction-envelopes.test.ts +73 -0
  184. package/src/messaging/reaction-envelopes.ts +33 -5
  185. package/src/messaging/read-provider-metadata.ts +4 -3
  186. package/src/monitoring/recovery/__tests__/stranded-delivery-events.test.ts +208 -0
  187. package/src/monitoring/recovery/db.ts +35 -0
  188. package/src/monitoring/recovery/orphaned-channel-events.ts +3 -22
  189. package/src/monitoring/recovery/run-recovery.ts +6 -2
  190. package/src/monitoring/recovery/stale-processing.ts +3 -20
  191. package/src/monitoring/recovery/stranded-delivery-events.ts +68 -0
  192. package/src/notifications/AGENTS.md +2 -2
  193. package/src/notifications/README.md +2 -2
  194. package/src/notifications/__tests__/assistant-reply-producer.test.ts +11 -9
  195. package/src/notifications/__tests__/broadcaster.test.ts +35 -4
  196. package/src/notifications/__tests__/notification-utils.test.ts +31 -0
  197. package/src/notifications/access-request-copy.ts +126 -114
  198. package/src/notifications/adapters/discord.ts +9 -5
  199. package/src/notifications/adapters/shared.ts +17 -4
  200. package/src/notifications/adapters/slack.ts +14 -4
  201. package/src/notifications/adapters/telegram.ts +8 -4
  202. package/src/notifications/approval-card-data.ts +5 -2
  203. package/src/notifications/assistant-reply-producer.ts +7 -7
  204. package/src/notifications/broadcaster.ts +57 -25
  205. package/src/notifications/conversation-pairing.ts +68 -2
  206. package/src/notifications/copy-composer.ts +13 -32
  207. package/src/notifications/decision-engine.ts +56 -237
  208. package/src/notifications/guardian-delivery-recorder.ts +5 -9
  209. package/src/notifications/guardian-feed-projection.ts +0 -15
  210. package/src/notifications/guardian-question-mode.ts +147 -6
  211. package/src/notifications/notification-utils.ts +117 -1
  212. package/src/permissions/confirmation-guardian-request.ts +2 -2
  213. package/src/permissions/prompter.ts +1 -1
  214. package/src/persistence/conversation-crud.ts +93 -14
  215. package/src/persistence/conversation-queries.ts +144 -17
  216. package/src/persistence/conversation-types.ts +39 -5
  217. package/src/persistence/delivery-crud.ts +224 -28
  218. package/src/persistence/delivery-status.ts +21 -2
  219. package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +26 -0
  220. package/src/persistence/migrations/375-create-channel-outbound-posts.ts +52 -0
  221. package/src/persistence/migrations/__tests__/375-create-channel-outbound-posts.test.ts +84 -0
  222. package/src/persistence/schema/conversations.ts +71 -2
  223. package/src/persistence/schema-contract.test.ts +78 -0
  224. package/src/persistence/schema-contract.ts +96 -0
  225. package/src/persistence/steps.ts +4 -0
  226. package/src/platform/client.ts +55 -0
  227. package/src/plugins/__tests__/mcp-servers.test.ts +46 -0
  228. package/src/plugins/defaults/injector-order.ts +2 -1
  229. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +2 -2
  230. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +13 -15
  231. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +10 -0
  232. package/src/plugins/defaults/memory/injectors.ts +1 -1
  233. package/src/plugins/defaults/memory/memory-marker.ts +17 -7
  234. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +1 -5
  235. package/src/plugins/defaults/memory/substrate/__tests__/skill-content.test.ts +17 -0
  236. package/src/plugins/defaults/memory/substrate/skill-content.ts +7 -0
  237. package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +54 -41
  238. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +3 -3
  239. package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +22 -0
  240. package/src/plugins/defaults/memory/v3/injector.ts +14 -15
  241. package/src/plugins/defaults/memory/v3/prune.test.ts +20 -2
  242. package/src/plugins/defaults/memory/v3/render-injection.ts +10 -1
  243. package/src/plugins/defaults/memory/v3/types.ts +15 -4
  244. package/src/plugins/defaults/turn-context/injectors.ts +3 -0
  245. package/src/plugins/defaults/turn-context/unified-turn-context.ts +24 -0
  246. package/src/plugins/mcp-servers.ts +14 -2
  247. package/src/providers/__tests__/dispatch-connection-routing.test.ts +50 -0
  248. package/src/providers/__tests__/registry-native-web-search.test.ts +49 -2
  249. package/src/providers/anthropic/client.ts +21 -24
  250. package/src/providers/call-site-routing.ts +5 -4
  251. package/src/providers/connection-resolution.ts +29 -1
  252. package/src/providers/content-block-size.test.ts +82 -0
  253. package/src/providers/content-block-size.ts +99 -0
  254. package/src/providers/file-block-text.test.ts +64 -0
  255. package/src/providers/file-block-text.ts +28 -0
  256. package/src/providers/gemini/client.ts +15 -9
  257. package/src/providers/inference/auth.ts +6 -6
  258. package/src/providers/media-resolve.ts +14 -0
  259. package/src/providers/model-catalog.ts +47 -34
  260. package/src/providers/openai/chat-completions-provider.ts +9 -25
  261. package/src/providers/openai/responses-provider.ts +11 -18
  262. package/src/providers/registry.ts +10 -7
  263. package/src/providers/routing-identity.ts +2 -1
  264. package/src/providers/types.ts +1 -4
  265. package/src/providers/vellum-model-routing.ts +3 -2
  266. package/src/runtime/AGENTS.md +40 -15
  267. package/src/runtime/approval-message-composer.ts +1 -6
  268. package/src/runtime/assistant-event-hub.ts +2 -39
  269. package/src/runtime/channel-approval-types.ts +1 -5
  270. package/src/runtime/channel-reply-delivery.ts +182 -115
  271. package/src/runtime/{slack-reply-session.test.ts → channel-reply-session.test.ts} +294 -156
  272. package/src/runtime/{slack-reply-session.ts → channel-reply-session.ts} +107 -86
  273. package/src/runtime/channel-retry-sweep.ts +102 -1
  274. package/src/runtime/finalize-event-delivery.ts +4 -4
  275. package/src/runtime/guardian-action-message-composer.ts +1 -4
  276. package/src/runtime/guardian-reply-router.ts +4 -75
  277. package/src/runtime/http-router.ts +1 -5
  278. package/src/runtime/question-request-guardian-bridge.ts +2 -3
  279. package/src/runtime/routes/__tests__/conversation-list-assistant-section.test.ts +359 -0
  280. package/src/runtime/routes/__tests__/dictation-command-mode.test.ts +120 -0
  281. package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +487 -0
  282. package/src/runtime/routes/acp-routes.ts +1 -1
  283. package/src/runtime/routes/canned-reply-release.ts +19 -9
  284. package/src/runtime/routes/channel-route-shared.ts +0 -39
  285. package/src/runtime/routes/channel-verification-routes.ts +4 -1
  286. package/src/runtime/routes/conversation-list-routes.ts +44 -3
  287. package/src/runtime/routes/conversation-management-routes.ts +2 -1
  288. package/src/runtime/routes/conversation-routes.ts +138 -59
  289. package/src/runtime/routes/diagnostics-routes.ts +111 -60
  290. package/src/runtime/routes/guardian-action-routes.ts +10 -14
  291. package/src/runtime/routes/guardian-approval-interception.ts +0 -5
  292. package/src/runtime/routes/inbound-message-handler.ts +170 -48
  293. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +4 -3
  294. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +169 -8
  295. package/src/runtime/routes/inbound-stages/background-dispatch.ts +82 -14
  296. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +23 -48
  297. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +454 -94
  298. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +309 -102
  299. package/src/runtime/routes/index.ts +2 -0
  300. package/src/runtime/routes/platform-routes.ts +99 -10
  301. package/src/runtime/routes/sight-frame-routes.ts +136 -0
  302. package/src/runtime/sync/resource-sync-events.ts +14 -37
  303. package/src/runtime/sync/sync-publisher.test.ts +5 -4
  304. package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
  305. package/src/tools/client-os.ts +11 -1
  306. package/src/tools/host-filesystem/edit.ts +2 -2
  307. package/src/tools/host-filesystem/read.ts +2 -2
  308. package/src/tools/host-filesystem/transfer.ts +2 -2
  309. package/src/tools/host-filesystem/write.ts +2 -2
  310. package/src/tools/host-terminal/host-shell.ts +2 -2
  311. package/src/tools/skills/load.ts +1 -1
  312. package/src/tools/terminal/safe-env.ts +4 -0
  313. package/src/tools/tool-input-schemas.ts +2 -0
  314. package/src/tools/tool-manifest.ts +2 -0
  315. package/src/tools/ui-surface/definitions.ts +4 -3
  316. package/src/tools/watch/watch-retro-report.ts +205 -0
  317. package/src/watch/__tests__/watch-retro.test.ts +268 -40
  318. package/src/watch/watch-retro.ts +190 -77
  319. package/src/workspace/migrations/151-repair-renamed-fireworks-deepseek-pro-model-id.ts +195 -0
  320. package/src/workspace/migrations/152-repair-retired-fireworks-minimax-m2p7-model-id.ts +198 -0
  321. package/src/workspace/migrations/__tests__/150-stt-flux-provider-to-model-family.test.ts +0 -10
  322. package/src/workspace/migrations/registry.ts +4 -0
  323. package/docker-kata-pip-chroot.sh +0 -22
  324. package/src/__tests__/slack-reaction-approvals.test.ts +0 -97
  325. package/src/__tests__/slack-reaction-guardian-approval.test.ts +0 -307
  326. package/src/api/events/conversation-list-invalidated.ts +0 -38
@@ -22,7 +22,22 @@ let streamOpImpl: (op: StreamOp) => Promise<{
22
22
  ok: boolean;
23
23
  ts?: string;
24
24
  }> = async () => ({ ok: true, ts: "stream-ts-1" });
25
+ /**
26
+ * What the transport lookup reports for the callback under test. A channel
27
+ * declares it can stream by implementing the method, so `undefined` here is a
28
+ * channel that cannot, and `streamPersists` is what says whether the stream
29
+ * leaves the reply behind.
30
+ */
31
+ let transportImpl:
32
+ | {
33
+ streamReply?: unknown;
34
+ streamPersists?: boolean;
35
+ maxStreamTextChars?: number;
36
+ }
37
+ | undefined = { streamReply: () => undefined, streamPersists: true };
38
+
25
39
  mock.module("../messaging/providers/index.js", () => ({
40
+ getTransportForCallback: () => transportImpl,
26
41
  sendChannelStreamOp: async (
27
42
  _callbackUrl: string,
28
43
  _chatId: string,
@@ -53,11 +68,10 @@ mock.module("./gateway-client.js", () => ({
53
68
  }));
54
69
 
55
70
  import type { AssistantEvent } from "../api/index.js";
56
- import { SLACK_STREAM_MARKDOWN_LIMIT } from "../messaging/providers/slack/api.js";
57
71
  import {
58
- createSlackReplySession,
59
- shouldStreamSlackReply,
60
- } from "./slack-reply-session.js";
72
+ channelCanStreamReply,
73
+ createChannelReplySession,
74
+ } from "./channel-reply-session.js";
61
75
 
62
76
  const CHANNEL = "D-STREAM";
63
77
  const THREAD_TS = "1700000000.000001";
@@ -155,105 +169,122 @@ const streamedMarkdown = (): string =>
155
169
  .join("");
156
170
 
157
171
  beforeEach(() => {
172
+ // A channel that streams and whose stream is the reply, which is what the
173
+ // suite below assumes unless a test says otherwise.
174
+ transportImpl = { streamReply: () => undefined, streamPersists: true };
158
175
  deliverCalls.length = 0;
159
176
  sentStreamOps.length = 0;
160
177
  streamOpImpl = async () => ({ ok: true, ts: "stream-ts-1" });
161
178
  deliverImpl = async () => ({ ok: true, ts: "stream-ts-1" });
162
179
  });
163
180
 
164
- describe("shouldStreamSlackReply", () => {
165
- test("admits a threaded Slack DM", () => {
166
- expect(
167
- shouldStreamSlackReply({
168
- sourceChannel: "slack",
169
- chatType: "im",
170
- replyCallbackUrl: CALLBACK_URL,
171
- }),
172
- ).toBe(true);
181
+ describe("channelCanStreamReply", () => {
182
+ test("a channel whose transport implements streamReply can stream", () => {
183
+ transportImpl = { streamReply: () => undefined, streamPersists: true };
184
+ expect(channelCanStreamReply(CALLBACK_URL)).toBe(true);
173
185
  });
174
186
 
175
- test("rejects a DM whose callback URL carries no thread_ts", () => {
176
- expect(
177
- shouldStreamSlackReply({
178
- sourceChannel: "slack",
179
- chatType: "im",
180
- replyCallbackUrl: `https://example.test/deliver/slack?channel=${CHANNEL}`,
181
- }),
182
- ).toBe(false);
187
+ test("a channel whose transport omits streamReply cannot", () => {
188
+ // Omitting the method is how a channel with no such primitive declares
189
+ // it, so there is nothing else to consult.
190
+ transportImpl = { streamPersists: false };
191
+ expect(channelCanStreamReply(CALLBACK_URL)).toBe(false);
183
192
  });
184
193
 
185
- test("admits a channel turn carrying both recipient IDs", () => {
186
- expect(
187
- shouldStreamSlackReply({
188
- sourceChannel: "slack",
189
- chatType: "channel",
190
- replyCallbackUrl: CALLBACK_URL,
191
- recipientUserId: "U123",
192
- recipientTeamId: "T123",
193
- }),
194
- ).toBe(true);
194
+ test("no callback URL is no channel to ask", () => {
195
+ expect(channelCanStreamReply(undefined)).toBe(false);
195
196
  });
196
197
 
197
- test("admits an app-mention turn (no chatType) carrying both recipient IDs", () => {
198
+ test("a session is not created for a channel that cannot stream", () => {
199
+ transportImpl = undefined;
198
200
  expect(
199
- shouldStreamSlackReply({
200
- sourceChannel: "slack",
201
+ createChannelReplySession({
201
202
  replyCallbackUrl: CALLBACK_URL,
202
- recipientUserId: "U123",
203
- recipientTeamId: "T123",
203
+ chatId: CHANNEL,
204
204
  }),
205
- ).toBe(true);
205
+ ).toBeUndefined();
206
206
  });
207
+ });
207
208
 
208
- test("rejects a channel turn missing the recipient team ID", () => {
209
- expect(
210
- shouldStreamSlackReply({
211
- sourceChannel: "slack",
212
- chatType: "channel",
213
- replyCallbackUrl: CALLBACK_URL,
214
- recipientUserId: "U123",
215
- }),
216
- ).toBe(false);
209
+ describe("createChannelReplySession", () => {
210
+ test("a start the channel refuses falls back without streaming", async () => {
211
+ // Whether THIS conversation can carry a growing reply is the platform's
212
+ // rule, answered by the transport's own reply to `start`. Slack refusing
213
+ // a turn with no thread, and Telegram refusing a chat that is not
214
+ // private, both arrive here as the same not-ok.
215
+ streamOpImpl = async () => ({ ok: false });
216
+ const session = createChannelReplySession({
217
+ replyCallbackUrl: CALLBACK_URL,
218
+ chatId: CHANNEL,
219
+ })!;
220
+
221
+ session.observeEvent(textDelta("The complete answer."));
222
+ session.observeEvent(messageComplete("assistant-msg-1"));
223
+ const reconciliation = await session.finish();
224
+
225
+ expect(slackStreamOps().map((op) => op.action)).toEqual(["start"]);
226
+ expect(reconciliation).toEqual({ mode: "fallback" });
217
227
  });
218
228
 
219
- test("rejects a channel turn missing the recipient user ID", () => {
220
- expect(
221
- shouldStreamSlackReply({
222
- sourceChannel: "slack",
223
- chatType: "channel",
224
- replyCallbackUrl: CALLBACK_URL,
225
- recipientTeamId: "T123",
226
- }),
227
- ).toBe(false);
229
+ test("a preview stream leaves the reply still owed", async () => {
230
+ // Telegram's draft evaporates rather than becoming the reply, so a
231
+ // channel that does not persist its stream reports `fallback`: durable
232
+ // delivery still sends the reply, exactly as for a channel that never
233
+ // streamed. Reporting `streamed` here would drop the reply entirely.
234
+ transportImpl = { streamReply: () => undefined, streamPersists: false };
235
+ const session = createChannelReplySession({
236
+ replyCallbackUrl: CALLBACK_URL,
237
+ chatId: CHANNEL,
238
+ })!;
239
+
240
+ session.observeEvent(textDelta("The complete answer."));
241
+ session.observeEvent(messageComplete("assistant-msg-1"));
242
+ const reconciliation = await session.finish();
243
+
244
+ // The preview really was streamed...
245
+ expect(slackStreamOps().map((op) => op.action)).toEqual(["start", "stop"]);
246
+ // ...and the reply is still owed.
247
+ expect(reconciliation).toEqual({ mode: "fallback" });
228
248
  });
229
249
 
230
- test("rejects non-Slack channels", () => {
231
- expect(
232
- shouldStreamSlackReply({
233
- sourceChannel: "telegram",
234
- chatType: "im",
235
- replyCallbackUrl: CALLBACK_URL,
236
- }),
237
- ).toBe(false);
250
+ test("a preview stream never records an id for crash recovery", async () => {
251
+ // The breadcrumb exists so a retry can reconcile against a message the
252
+ // reader can already see. A preview leaves none, and its id names nothing
253
+ // durable, so recording it would send recovery to edit a message that
254
+ // never existed and lose the reply instead of posting it.
255
+ transportImpl = { streamReply: () => undefined, streamPersists: false };
256
+ const opened: string[] = [];
257
+ const session = createChannelReplySession({
258
+ replyCallbackUrl: CALLBACK_URL,
259
+ chatId: CHANNEL,
260
+ onStreamOpen: (ts) => opened.push(ts),
261
+ })!;
262
+
263
+ session.observeEvent(textDelta("The complete answer."));
264
+ session.observeEvent(messageComplete("assistant-msg-1"));
265
+ await session.finish();
266
+
267
+ expect(slackStreamOps().map((op) => op.action)).toEqual(["start", "stop"]);
268
+ expect(opened).toEqual([]);
238
269
  });
239
- });
240
270
 
241
- describe("createSlackReplySession", () => {
242
- test("returns undefined for an ineligible turn", () => {
243
- expect(
244
- createSlackReplySession({
245
- sourceChannel: "slack",
246
- chatType: "channel",
247
- replyCallbackUrl: CALLBACK_URL,
248
- chatId: CHANNEL,
249
- }),
250
- ).toBeUndefined();
271
+ test("a stream that becomes the reply does record its id", async () => {
272
+ const opened: string[] = [];
273
+ const session = createChannelReplySession({
274
+ replyCallbackUrl: CALLBACK_URL,
275
+ chatId: CHANNEL,
276
+ onStreamOpen: (ts) => opened.push(ts),
277
+ })!;
278
+
279
+ session.observeEvent(textDelta("The complete answer."));
280
+ session.observeEvent(messageComplete("assistant-msg-1"));
281
+ await session.finish();
282
+
283
+ expect(opened).toEqual(["stream-ts-1"]);
251
284
  });
252
285
 
253
286
  test("streams a fast turn as a single start then stop", async () => {
254
- const session = createSlackReplySession({
255
- sourceChannel: "slack",
256
- chatType: "im",
287
+ const session = createChannelReplySession({
257
288
  replyCallbackUrl: CALLBACK_URL,
258
289
  chatId: CHANNEL,
259
290
  })!;
@@ -266,7 +297,6 @@ describe("createSlackReplySession", () => {
266
297
  expect(slackStreamOps()).toEqual([
267
298
  {
268
299
  action: "start",
269
- anchorMessageId: THREAD_TS,
270
300
  text: "The complete answer.",
271
301
  appended: "The complete answer.",
272
302
  },
@@ -284,9 +314,7 @@ describe("createSlackReplySession", () => {
284
314
  });
285
315
 
286
316
  test("stamps recipient IDs on the start op for a channel turn", async () => {
287
- const session = createSlackReplySession({
288
- sourceChannel: "slack",
289
- chatType: "channel",
317
+ const session = createChannelReplySession({
290
318
  replyCallbackUrl: CALLBACK_URL,
291
319
  chatId: CHANNEL,
292
320
  recipientUserId: "U123",
@@ -301,7 +329,6 @@ describe("createSlackReplySession", () => {
301
329
  expect(slackStreamOps()).toEqual([
302
330
  {
303
331
  action: "start",
304
- anchorMessageId: THREAD_TS,
305
332
  text: "The complete answer.",
306
333
  appended: "The complete answer.",
307
334
  audience: { kind: "oneReader", userId: "U123", userOrgId: "T123" },
@@ -315,9 +342,7 @@ describe("createSlackReplySession", () => {
315
342
  });
316
343
 
317
344
  test("coalesces mid-stream deltas into incremental appends", async () => {
318
- const session = createSlackReplySession({
319
- sourceChannel: "slack",
320
- chatType: "im",
345
+ const session = createChannelReplySession({
321
346
  replyCallbackUrl: CALLBACK_URL,
322
347
  chatId: CHANNEL,
323
348
  coalesceMs: 5,
@@ -332,7 +357,6 @@ describe("createSlackReplySession", () => {
332
357
  expect(slackStreamOps()).toEqual([
333
358
  {
334
359
  action: "start",
335
- anchorMessageId: THREAD_TS,
336
360
  text: "First half. ",
337
361
  appended: "First half. ",
338
362
  },
@@ -351,32 +375,101 @@ describe("createSlackReplySession", () => {
351
375
  expect(reconciliation.mode).toBe("streamed");
352
376
  });
353
377
 
354
- test("drains a body wider than the markdown limit across calls", async () => {
355
- const session = createSlackReplySession({
356
- sourceChannel: "slack",
357
- chatType: "im",
378
+ test("drains a body wider than the channel's cap, one operation per chunk", async () => {
379
+ transportImpl = {
380
+ streamReply: () => undefined,
381
+ streamPersists: true,
382
+ maxStreamTextChars: 10,
383
+ };
384
+ const session = createChannelReplySession({
385
+ replyCallbackUrl: CALLBACK_URL,
386
+ chatId: CHANNEL,
387
+ })!;
388
+
389
+ const body = "abcdefghijKLMNOPQRSTuvwxy";
390
+ session.observeEvent(textDelta(body));
391
+ session.observeEvent(messageComplete("assistant-msg-1"));
392
+ await session.finish();
393
+
394
+ const ops = slackStreamOps();
395
+ expect(ops.map((op) => op.action)).toEqual([
396
+ "start",
397
+ "append",
398
+ "append",
399
+ "stop",
400
+ ]);
401
+ // Every chunk fits the cap, and together they are the whole reply.
402
+ expect(ops[0]!.appended).toBe("abcdefghij");
403
+ expect(ops[1]!.appended).toBe("KLMNOPQRST");
404
+ expect(ops[2]!.appended).toBe("uvwxy");
405
+ });
406
+
407
+ test("a failed chunk resumes at itself, never at what already landed", async () => {
408
+ // The delivered mark advances once per confirmed operation, so a retry
409
+ // begins at the chunk that failed. Advancing it for a delta the channel
410
+ // only partly took would resume past unsent text, or re-send text the
411
+ // reader can already see.
412
+ transportImpl = {
413
+ streamReply: () => undefined,
414
+ streamPersists: true,
415
+ maxStreamTextChars: 10,
416
+ };
417
+ const session = createChannelReplySession({
358
418
  replyCallbackUrl: CALLBACK_URL,
359
419
  chatId: CHANNEL,
420
+ coalesceMs: 5,
360
421
  })!;
361
422
 
362
- const body = "x".repeat(SLACK_STREAM_MARKDOWN_LIMIT + 4_000);
423
+ let failNextAppend = true;
424
+ streamOpImpl = async (op) => {
425
+ if (op.action === "append" && failNextAppend) {
426
+ failNextAppend = false;
427
+ throw new Error("Simulated append failure");
428
+ }
429
+ return { ok: true, ts: "stream-ts-1" };
430
+ };
431
+
432
+ session.observeEvent(textDelta("abcdefghijKLMNOPQRST"));
433
+ await tick(15);
434
+ session.observeEvent(textDelta("uvwxy"));
435
+ await tick(15);
436
+ session.observeEvent(messageComplete("assistant-msg-1"));
437
+ await session.finish();
438
+
439
+ const appended = slackStreamOps()
440
+ .filter((op) => op.action === "append")
441
+ .map((op) => op.appended);
442
+ // The first attempt at "KLMNOPQRST" fails and is retried; nothing that
443
+ // landed is ever sent a second time.
444
+ expect(appended[0]).toBe("KLMNOPQRST");
445
+ expect(appended[1]).toBe("KLMNOPQRST");
446
+ expect(appended.filter((c) => c === "abcdefghij")).toHaveLength(0);
447
+ expect(appended.at(-1)).toBe("uvwxy");
448
+ });
449
+
450
+ test("hands a body of any width over as one delta", async () => {
451
+ // Splitting belongs to the channel whose API sets the cap, so the session
452
+ // must not pre-split: a channel with a wider cap, or none, would be paying
453
+ // for Slack's. What it owes is the whole delta, once.
454
+ const session = createChannelReplySession({
455
+ replyCallbackUrl: CALLBACK_URL,
456
+ chatId: CHANNEL,
457
+ })!;
458
+
459
+ const body = "x".repeat(20_000);
363
460
  session.observeEvent(textDelta(body));
364
461
  session.observeEvent(messageComplete("assistant-msg-1"));
365
462
  await session.finish();
366
463
 
367
464
  const ops = slackStreamOps();
368
- expect(ops.map((op) => op.action)).toEqual(["start", "append", "stop"]);
369
- expect((ops[0]!.appended ?? "").length).toBe(SLACK_STREAM_MARKDOWN_LIMIT);
370
- expect((ops[1]!.appended ?? "").length).toBe(4_000);
371
- const streamed = (ops[0]!.appended ?? "") + (ops[1]!.appended ?? "");
372
- expect(streamed).toBe(body);
465
+ expect(ops.map((op) => op.action)).toEqual(["start", "stop"]);
466
+ expect(ops[0]!.appended).toBe(body);
467
+ expect(ops[0]!.text).toBe(body);
373
468
  });
374
469
 
375
470
  test("falls back when startStream returns no stream ts", async () => {
376
471
  streamOpImpl = async () => ({ ok: false });
377
- const session = createSlackReplySession({
378
- sourceChannel: "slack",
379
- chatType: "im",
472
+ const session = createChannelReplySession({
380
473
  replyCallbackUrl: CALLBACK_URL,
381
474
  chatId: CHANNEL,
382
475
  })!;
@@ -393,9 +486,7 @@ describe("createSlackReplySession", () => {
393
486
  streamOpImpl = async () => {
394
487
  throw new Error("rate limited");
395
488
  };
396
- const session = createSlackReplySession({
397
- sourceChannel: "slack",
398
- chatType: "im",
489
+ const session = createChannelReplySession({
399
490
  replyCallbackUrl: CALLBACK_URL,
400
491
  chatId: CHANNEL,
401
492
  })!;
@@ -414,9 +505,7 @@ describe("createSlackReplySession", () => {
414
505
  }
415
506
  return { ok: true, ts: "stream-ts-1" };
416
507
  };
417
- const session = createSlackReplySession({
418
- sourceChannel: "slack",
419
- chatType: "im",
508
+ const session = createChannelReplySession({
420
509
  replyCallbackUrl: CALLBACK_URL,
421
510
  chatId: CHANNEL,
422
511
  })!;
@@ -435,9 +524,7 @@ describe("createSlackReplySession", () => {
435
524
  // transient breadcrumb write error) must not knock the session into
436
525
  // fallback — that would repost the already-visible streamed reply.
437
526
  const onStreamOpenCalls: string[] = [];
438
- const session = createSlackReplySession({
439
- sourceChannel: "slack",
440
- chatType: "im",
527
+ const session = createChannelReplySession({
441
528
  replyCallbackUrl: CALLBACK_URL,
442
529
  chatId: CHANNEL,
443
530
  onStreamOpen: (streamTs) => {
@@ -466,9 +553,7 @@ describe("createSlackReplySession", () => {
466
553
  // `chat.appendStream` accepts a chunks-only call, so a plan that advances
467
554
  // during tool work lands live instead of waiting for the final stop.
468
555
  // @see https://docs.slack.dev/reference/methods/chat.appendStream/
469
- const session = createSlackReplySession({
470
- sourceChannel: "slack",
471
- chatType: "im",
556
+ const session = createChannelReplySession({
472
557
  replyCallbackUrl: CALLBACK_URL,
473
558
  chatId: CHANNEL,
474
559
  coalesceMs: 5,
@@ -522,9 +607,7 @@ describe("createSlackReplySession", () => {
522
607
  });
523
608
 
524
609
  test("does not re-append unchanged task progress", async () => {
525
- const session = createSlackReplySession({
526
- sourceChannel: "slack",
527
- chatType: "im",
610
+ const session = createChannelReplySession({
528
611
  replyCallbackUrl: CALLBACK_URL,
529
612
  chatId: CHANNEL,
530
613
  coalesceMs: 5,
@@ -548,9 +631,7 @@ describe("createSlackReplySession", () => {
548
631
  });
549
632
 
550
633
  test("does not advance a plan the surface tool rejected", async () => {
551
- const session = createSlackReplySession({
552
- sourceChannel: "slack",
553
- chatType: "im",
634
+ const session = createChannelReplySession({
554
635
  replyCallbackUrl: CALLBACK_URL,
555
636
  chatId: CHANNEL,
556
637
  coalesceMs: 5,
@@ -598,9 +679,7 @@ describe("createSlackReplySession", () => {
598
679
  }
599
680
  return { ok: true, ts: "stream-ts-1" };
600
681
  };
601
- const session = createSlackReplySession({
602
- sourceChannel: "slack",
603
- chatType: "im",
682
+ const session = createChannelReplySession({
604
683
  replyCallbackUrl: CALLBACK_URL,
605
684
  chatId: CHANNEL,
606
685
  coalesceMs: 5,
@@ -639,9 +718,7 @@ describe("createSlackReplySession", () => {
639
718
  });
640
719
 
641
720
  test("never opens a stream for a no_response-only turn", async () => {
642
- const session = createSlackReplySession({
643
- sourceChannel: "slack",
644
- chatType: "im",
721
+ const session = createChannelReplySession({
645
722
  replyCallbackUrl: CALLBACK_URL,
646
723
  chatId: CHANNEL,
647
724
  })!;
@@ -657,9 +734,7 @@ describe("createSlackReplySession", () => {
657
734
  test("holds the stream while a no_response sentinel arrives in pieces", async () => {
658
735
  // A coalesce timer must not open a stream on the leading `<` of a slowly
659
736
  // streamed `<no_response/>`, which would leak a stray partial message.
660
- const session = createSlackReplySession({
661
- sourceChannel: "slack",
662
- chatType: "im",
737
+ const session = createChannelReplySession({
663
738
  replyCallbackUrl: CALLBACK_URL,
664
739
  chatId: CHANNEL,
665
740
  coalesceMs: 5,
@@ -685,9 +760,7 @@ describe("createSlackReplySession", () => {
685
760
  // Slack streams are append-only, so a `[label](vellum://…)` link split
686
761
  // across deltas must not stream its internal path before the closing `)`
687
762
  // arrives — once emitted it could not be retracted.
688
- const session = createSlackReplySession({
689
- sourceChannel: "slack",
690
- chatType: "im",
763
+ const session = createChannelReplySession({
691
764
  replyCallbackUrl: CALLBACK_URL,
692
765
  chatId: CHANNEL,
693
766
  coalesceMs: 5,
@@ -714,9 +787,7 @@ describe("createSlackReplySession", () => {
714
787
  });
715
788
 
716
789
  test("counts deliverable text segments split at tool boundaries", async () => {
717
- const session = createSlackReplySession({
718
- sourceChannel: "slack",
719
- chatType: "im",
790
+ const session = createChannelReplySession({
720
791
  replyCallbackUrl: CALLBACK_URL,
721
792
  chatId: CHANNEL,
722
793
  })!;
@@ -738,9 +809,7 @@ describe("createSlackReplySession", () => {
738
809
  // The model ends one segment with a period and opens the next with a
739
810
  // capital letter, supplying no separating whitespace on either side.
740
811
  // Concatenating them raw would fuse "Sentence one.Sentence two.".
741
- const session = createSlackReplySession({
742
- sourceChannel: "slack",
743
- chatType: "im",
812
+ const session = createChannelReplySession({
744
813
  replyCallbackUrl: CALLBACK_URL,
745
814
  chatId: CHANNEL,
746
815
  })!;
@@ -758,9 +827,7 @@ describe("createSlackReplySession", () => {
758
827
  // Multiple `message_complete` events fire within one streamed turn (one
759
828
  // per model response). Text from the second response must not fuse onto
760
829
  // the first.
761
- const session = createSlackReplySession({
762
- sourceChannel: "slack",
763
- chatType: "im",
830
+ const session = createChannelReplySession({
764
831
  replyCallbackUrl: CALLBACK_URL,
765
832
  chatId: CHANNEL,
766
833
  })!;
@@ -775,9 +842,7 @@ describe("createSlackReplySession", () => {
775
842
  });
776
843
 
777
844
  test("does not double-space a boundary the model already spaced", async () => {
778
- const session = createSlackReplySession({
779
- sourceChannel: "slack",
780
- chatType: "im",
845
+ const session = createChannelReplySession({
781
846
  replyCallbackUrl: CALLBACK_URL,
782
847
  chatId: CHANNEL,
783
848
  })!;
@@ -794,9 +859,7 @@ describe("createSlackReplySession", () => {
794
859
  test("does not fuse mid-word deltas within a single segment", async () => {
795
860
  // Intra-segment token deltas carry the model's own spacing and must never
796
861
  // be altered — only tool/message boundaries introduce a separating space.
797
- const session = createSlackReplySession({
798
- sourceChannel: "slack",
799
- chatType: "im",
862
+ const session = createChannelReplySession({
800
863
  replyCallbackUrl: CALLBACK_URL,
801
864
  chatId: CHANNEL,
802
865
  })!;
@@ -811,9 +874,7 @@ describe("createSlackReplySession", () => {
811
874
  });
812
875
 
813
876
  test("opens the stream in plan mode and advances task cards", async () => {
814
- const session = createSlackReplySession({
815
- sourceChannel: "slack",
816
- chatType: "im",
877
+ const session = createChannelReplySession({
817
878
  replyCallbackUrl: CALLBACK_URL,
818
879
  chatId: CHANNEL,
819
880
  coalesceMs: 5,
@@ -841,7 +902,6 @@ describe("createSlackReplySession", () => {
841
902
  const ops = slackStreamOps();
842
903
  expect(ops[0]).toEqual({
843
904
  action: "start",
844
- anchorMessageId: THREAD_TS,
845
905
  text: "Working on it.",
846
906
  appended: "Working on it.",
847
907
  plan: {
@@ -871,9 +931,7 @@ describe("createSlackReplySession", () => {
871
931
  // stream starts, so the start must open in plan mode even with no plan
872
932
  // active yet — otherwise the late-arriving task cards can never render
873
933
  // as a plan.
874
- const session = createSlackReplySession({
875
- sourceChannel: "slack",
876
- chatType: "im",
934
+ const session = createChannelReplySession({
877
935
  replyCallbackUrl: CALLBACK_URL,
878
936
  chatId: CHANNEL,
879
937
  coalesceMs: 5,
@@ -895,7 +953,6 @@ describe("createSlackReplySession", () => {
895
953
  expect(slackStreamOps()).toEqual([
896
954
  {
897
955
  action: "start",
898
- anchorMessageId: THREAD_TS,
899
956
  text: "On it, starting now.",
900
957
  appended: "On it, starting now.",
901
958
  },
@@ -924,10 +981,92 @@ describe("createSlackReplySession", () => {
924
981
  ]);
925
982
  });
926
983
 
984
+ test("opens the stream on a plan shown before any prose", async () => {
985
+ // What the Slack `ui_show` description actually instructs: show the card
986
+ // early on a multi-step turn. At that moment the model has written no
987
+ // prose, so a stream that waits for text holds the plan back until the
988
+ // final answer, when the steps it narrates are already finished.
989
+ const session = createChannelReplySession({
990
+ replyCallbackUrl: CALLBACK_URL,
991
+ chatId: CHANNEL,
992
+ coalesceMs: 5,
993
+ })!;
994
+
995
+ session.observeEvent(
996
+ taskProgressShow([
997
+ { label: "Search docs", status: "in_progress" },
998
+ { label: "Summarize", status: "pending" },
999
+ ]),
1000
+ );
1001
+ session.observeEvent(toolOk("tool-ui-show", "ui_show"));
1002
+ await tick(15);
1003
+
1004
+ // The plan is on Slack before the turn has produced a single character.
1005
+ expect(slackStreamOps()).toEqual([
1006
+ {
1007
+ action: "start",
1008
+ text: "",
1009
+ appended: "",
1010
+ plan: {
1011
+ steps: [
1012
+ { label: "Search docs", status: "in_progress" },
1013
+ { label: "Summarize", status: "pending" },
1014
+ ],
1015
+ },
1016
+ },
1017
+ ]);
1018
+ });
1019
+
1020
+ test("advances plan steps live while the turn is still working", async () => {
1021
+ const session = createChannelReplySession({
1022
+ replyCallbackUrl: CALLBACK_URL,
1023
+ chatId: CHANNEL,
1024
+ coalesceMs: 5,
1025
+ })!;
1026
+
1027
+ session.observeEvent(
1028
+ taskProgressShow([
1029
+ { label: "Search docs", status: "in_progress" },
1030
+ { label: "Summarize", status: "pending" },
1031
+ ]),
1032
+ );
1033
+ session.observeEvent(toolOk("tool-ui-show", "ui_show"));
1034
+ await tick(15);
1035
+ session.observeEvent(
1036
+ taskProgressUpdate([
1037
+ { label: "Search docs", status: "completed" },
1038
+ { label: "Summarize", status: "in_progress" },
1039
+ ]),
1040
+ );
1041
+ session.observeEvent(toolOk("tool-ui-update", "ui_update"));
1042
+ await tick(15);
1043
+
1044
+ // The advance rides its own task-only append rather than waiting for the
1045
+ // first text, which is the whole point of a live step tracker.
1046
+ const advances = slackStreamOps().filter((op) => op.action === "append");
1047
+ expect(advances).toHaveLength(1);
1048
+ expect(advances[0].plan).toEqual({
1049
+ steps: [
1050
+ { label: "Search docs", status: "completed" },
1051
+ { label: "Summarize", status: "in_progress" },
1052
+ ],
1053
+ });
1054
+
1055
+ session.observeEvent(textDelta("All done."));
1056
+ session.observeEvent(messageComplete("assistant-msg-1"));
1057
+ const reconciliation = await session.finish();
1058
+
1059
+ // The reply still streams into the same message the plan opened.
1060
+ expect(reconciliation).toEqual({
1061
+ mode: "streamed",
1062
+ messageTs: "stream-ts-1",
1063
+ deliveredSegmentCount: 1,
1064
+ });
1065
+ expect(streamedMarkdown()).toBe("All done.");
1066
+ });
1067
+
927
1068
  test("carries the plan title and step details onto stream ops", async () => {
928
- const session = createSlackReplySession({
929
- sourceChannel: "slack",
930
- chatType: "im",
1069
+ const session = createChannelReplySession({
931
1070
  replyCallbackUrl: CALLBACK_URL,
932
1071
  chatId: CHANNEL,
933
1072
  coalesceMs: 5,
@@ -955,7 +1094,6 @@ describe("createSlackReplySession", () => {
955
1094
  const ops = slackStreamOps();
956
1095
  expect(ops[0]).toEqual({
957
1096
  action: "start",
958
- anchorMessageId: THREAD_TS,
959
1097
  text: "Working on it.",
960
1098
  appended: "Working on it.",
961
1099
  plan: {