@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
@@ -112,8 +112,8 @@ import {
112
112
  type VoiceEndpointSource,
113
113
  } from "./live-voice-metrics.js";
114
114
  import {
115
+ persistAmbientSightFrame,
115
116
  persistLiveVoicePhoto,
116
- persistLiveVoiceSightFrame,
117
117
  } from "./live-voice-photo.js";
118
118
  import {
119
119
  type LiveVoiceSession as LiveVoiceSessionContract,
@@ -1694,9 +1694,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1694
1694
  * same fact.
1695
1695
  */
1696
1696
  private persistSightFrame(frame: LiveVoiceClientSightFrameFrame): void {
1697
- void persistLiveVoiceSightFrame(
1697
+ void persistAmbientSightFrame(
1698
1698
  this.conversationId,
1699
1699
  frame.attachmentId,
1700
+ "voice",
1700
1701
  ).then((result) => {
1701
1702
  if (!result.ok && !this.isClosed) {
1702
1703
  void this.sendFrame({
@@ -1707,6 +1708,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1707
1708
  // preview it already showed, rather than filing this with the
1708
1709
  // transient transcriber and TTS blips that share `recoverable`.
1709
1710
  frameType: "sight_frame",
1711
+ // Which keep, not just which stream: several can be outstanding
1712
+ // while a persist waits out a turn.
1713
+ attachmentId: frame.attachmentId,
1710
1714
  // The session is fine; only this frame failed.
1711
1715
  recoverable: true,
1712
1716
  });
@@ -272,7 +272,9 @@ export interface LiveVoiceClientAttachFrameFrame {
272
272
  * "sight_frame"` and `recoverable: true`: the session is fine and only this
273
273
  * frame failed. Attributing it is what lets the client retract the preview it
274
274
  * already showed instead of filing the error with the transient transcriber
275
- * and TTS blips that share `recoverable`.
275
+ * and TTS blips that share `recoverable`. The refusal echoes the frame's
276
+ * `attachmentId` too, because keeps overlap: see that field on
277
+ * {@link LiveVoiceErrorServerFrame}.
276
278
  */
277
279
  export interface LiveVoiceClientSightFrameFrame {
278
280
  readonly type: "sight_frame";
@@ -602,6 +604,23 @@ export interface LiveVoiceErrorServerFrame extends LiveVoiceServerFrameBase {
602
604
  * from older daemons) means the error is terminal for the session.
603
605
  */
604
606
  readonly recoverable?: boolean;
607
+ /**
608
+ * The attachment the refused frame named, present on `sight_frame`
609
+ * rejections so the client can retire the exact keep that failed.
610
+ *
611
+ * `frameType` alone only narrows a rejection to the keep stream, and that
612
+ * stream is the one place several sends are routinely outstanding at once:
613
+ * the camera keeps shooting while a persist waits out a running turn, so a
614
+ * client holding three unacknowledged keeps cannot tell which of them this
615
+ * error is about. Naming the id is what turns "a frame failed" into "this
616
+ * preview comes down".
617
+ *
618
+ * Optional, and no other error path populates it: a parse failure has no
619
+ * id to name, and the other frames carrying an attachment have at most one
620
+ * in flight, which `frameType` already identifies. Clients must tolerate
621
+ * an absent id and must not gate any behavior on its presence.
622
+ */
623
+ readonly attachmentId?: string;
605
624
  }
606
625
 
607
626
  export type LiveVoiceServerFrame =
@@ -1,3 +1,4 @@
1
+ import { ReactionEmojiFieldsSchema } from "@vellumai/service-contracts/reactions";
1
2
  import { z } from "zod";
2
3
 
3
4
  import { CHANNEL_IDS } from "../channels/types.js";
@@ -40,7 +41,21 @@ const providerReactionMetadataSchema = z.object({
40
41
  * namespace as `messageId`. Resolution is keyed on it, so it is required.
41
42
  */
42
43
  targetMessageId: z.string(),
44
+ /**
45
+ * The emoji in the channel's own spelling. Kept because the channel's
46
+ * write path consumes it and the model hands it back verbatim; the typed
47
+ * fields beside it are what a reader consults.
48
+ */
43
49
  emoji: z.string(),
50
+ /**
51
+ * Which namespace the emoji was drawn from, as the channel said it. Spread
52
+ * from the shared group rather than restated, and declared rather than
53
+ * left to passthrough: the outer envelope passes unknown keys through, but
54
+ * this nested object strips them, so an undeclared field writes fine and
55
+ * reads back as nothing with no error at either end. A row carrying only
56
+ * the spelling has its kind recovered by `resolveInboundReactionPayload`.
57
+ */
58
+ ...ReactionEmojiFieldsSchema.shape,
44
59
  op: z.enum(["added", "removed"]),
45
60
  actorDisplayName: z.string().optional(),
46
61
  });
@@ -65,7 +80,7 @@ export const providerMessageMetadataSchema = z
65
80
  * Provider ids of the further posts this row's delivery produced beyond
66
81
  * `messageId`: one stored reply split at tool boundaries or length
67
82
  * limits posts several provider messages, and they all belong to this
68
- * one row. A reaction naming any of them resolves here.
83
+ * one row. A reaction or delete naming any of them resolves here.
69
84
  */
70
85
  additionalMessageIds: z.array(z.string()).optional(),
71
86
  /**
@@ -89,7 +104,19 @@ export const providerMessageMetadataSchema = z
89
104
  eventKind: z.enum(["message", "reaction"]),
90
105
  reaction: providerReactionMetadataSchema.optional(),
91
106
  editedAt: z.number().optional(),
107
+ /**
108
+ * The row is no longer visible on the channel: every provider post it
109
+ * produced has been deleted. Readers treat this as the whole row's
110
+ * deletion mark, as before rows could name several posts.
111
+ */
92
112
  deletedAt: z.number().optional(),
113
+ /**
114
+ * Provider ids among `messageId`/`additionalMessageIds` whose posts have
115
+ * been deleted on the channel. A split reply can lose one post while the
116
+ * others stay visible, so deletion is tracked per id; `deletedAt` is
117
+ * stamped only once every id is here.
118
+ */
119
+ deletedMessageIds: z.array(z.string()).optional(),
93
120
  })
94
121
  .passthrough();
95
122
 
@@ -100,6 +127,18 @@ export type ProviderMessageMetadata = z.infer<
100
127
  typeof providerMessageMetadataSchema
101
128
  >;
102
129
 
130
+ /**
131
+ * Whether every post a row names is among its deleted ones, which is exactly
132
+ * when the row-level `deletedAt` is warranted. A row naming no post is a
133
+ * single post by construction, so an empty list reads as fully deleted.
134
+ */
135
+ export function everyPostDeleted(
136
+ postIds: readonly string[],
137
+ deletedIds: readonly string[] | undefined,
138
+ ): boolean {
139
+ return postIds.every((id) => deletedIds?.includes(id) === true);
140
+ }
141
+
103
142
  /**
104
143
  * Parse and validate a serialized `ProviderMessageMetadata`, the counterpart of
105
144
  * `readSlackMetadata` for the neutral shape. Anything that does not parse or
@@ -23,6 +23,10 @@ const slack = {
23
23
  ),
24
24
  };
25
25
  const telegram = {
26
+ // Spread the real module so a stub listing only what today's tests touch
27
+ // cannot break the next import a transport adds; the mocks below then
28
+ // override exactly the calls this suite asserts on.
29
+ ...(await import("../telegram-bot/send.js")),
26
30
  editTelegramMessage: mock((..._args: unknown[]) => Promise.resolve()),
27
31
  sendTelegramReaction: mock((..._args: unknown[]) =>
28
32
  Promise.resolve({ ok: true }),
@@ -90,8 +94,12 @@ function payload(
90
94
 
91
95
  beforeEach(() => {
92
96
  for (const group of [slack, telegram, whatsapp, a2a, discord]) {
93
- for (const spy of Object.values(group)) {
94
- spy.mockClear();
97
+ for (const value of Object.values(group)) {
98
+ // A group spreads its real module so a stub cannot fall behind the
99
+ // exports a transport imports, which means it also carries constants
100
+ // and untouched functions. Only the spies have anything to clear.
101
+ const spy = value as { mockClear?: () => void };
102
+ spy.mockClear?.();
95
103
  }
96
104
  }
97
105
  });
@@ -189,4 +189,32 @@ export interface ChannelTransport {
189
189
  chatId: string,
190
190
  op: StreamOp,
191
191
  ): Promise<ChannelDeliveryResult>;
192
+
193
+ /**
194
+ * The most text one stream operation may carry, for a channel that caps it.
195
+ *
196
+ * Declared rather than applied here because the caller is what knows how
197
+ * much of the reply a channel has actually accepted: it must advance that
198
+ * mark once per operation the channel confirms. A transport that split a
199
+ * wide delta into several calls of its own would leave the caller unable to
200
+ * tell a partial delivery from a whole one, and a retry would then re-send
201
+ * the part that already landed. Omitted by a channel with no cap.
202
+ */
203
+ readonly maxStreamTextChars?: number;
204
+
205
+ /**
206
+ * Whether what `streamReply` leaves behind is the reply itself.
207
+ *
208
+ * True for a channel that finalizes the streamed message in place, so the
209
+ * reply is already delivered once the stream ends and durable delivery must
210
+ * not send it again. Omitted by a channel whose stream is only a preview:
211
+ * the draft evaporates and the reply is still owed, so durable delivery
212
+ * sends it as it would for a channel that never streamed.
213
+ *
214
+ * Omission is the safe default on purpose. A channel that forgets to
215
+ * declare it posts the reply through the ordinary path, which at worst
216
+ * repeats what a persisting stream already showed; the opposite mistake
217
+ * loses the reply entirely.
218
+ */
219
+ readonly streamPersists?: boolean;
192
220
  }
@@ -9,6 +9,7 @@ import type {
9
9
  ApprovalUIMetadata,
10
10
  ChannelDeliveryResult,
11
11
  } from "@vellumai/gateway-client";
12
+ import { parseDiscordEmojiMention } from "@vellumai/gateway-client";
12
13
 
13
14
  import { getAttachmentContent } from "../../../persistence/attachments-store.js";
14
15
  import type { RuntimeAttachmentMetadata } from "../../../runtime/http-types.js";
@@ -424,8 +425,8 @@ export async function sendDiscordAttachments(
424
425
  * as itself.
425
426
  */
426
427
  function discordReactionPathEmoji(emoji: string): string {
427
- const custom = /^<a?:([^:>]+):(\d+)>$/.exec(emoji);
428
- return encodeURIComponent(custom ? `${custom[1]}:${custom[2]}` : emoji);
428
+ const custom = parseDiscordEmojiMention(emoji);
429
+ return encodeURIComponent(custom ? `${custom.name}:${custom.id}` : emoji);
429
430
  }
430
431
 
431
432
  /**
@@ -30,7 +30,17 @@ import {
30
30
  type SlackRequestOptions,
31
31
  } from "./web-api-transport.js";
32
32
 
33
- /** Envelope fields the outbound surfaces read off successful responses. */
33
+ /**
34
+ * Envelope fields the outbound surfaces read off successful responses.
35
+ *
36
+ * Deliberately no `message_ts`: that is what `chat.postEphemeral` returns
37
+ * instead of `ts`, and Slack documents it as not addressing a persisted
38
+ * message (`chat.update` rejects it). So an ephemeral reply has no id worth
39
+ * recording, and the absent `ts` correctly leaves its row unstamped rather
40
+ * than carrying a `channelTs` whose permalink resolves to nothing.
41
+ *
42
+ * @see https://docs.slack.dev/reference/methods/chat.postEphemeral/
43
+ */
34
44
  interface SlackOutboundApiResponse extends SlackApiResponse {
35
45
  ts?: string;
36
46
  upload_url?: string;
@@ -4,9 +4,11 @@ import {
4
4
  buildSlackTimezoneMetadata,
5
5
  formatSlackTimezoneLabel,
6
6
  mergeSlackMetadata,
7
+ providerMetadataOfPreSendSlackEnvelope,
7
8
  readSlackMetadata,
8
9
  readSlackMetadataFromMessageMetadata,
9
10
  type SlackMessageMetadata,
11
+ slackViewOfProviderMetadata,
10
12
  writeSlackMetadata,
11
13
  } from "./message-metadata.js";
12
14
 
@@ -394,3 +396,134 @@ describe("mergeSlackMetadata", () => {
394
396
  expect(parsed?.displayName).toBe("Bob");
395
397
  });
396
398
  });
399
+
400
+ describe("providerMetadataOfPreSendSlackEnvelope", () => {
401
+ test("reads a pre-send Slack envelope as the neutral shape, Slack's own fields riding along", () => {
402
+ expect(
403
+ providerMetadataOfPreSendSlackEnvelope({
404
+ slackMeta: JSON.stringify({
405
+ source: "slack",
406
+ eventKind: "message",
407
+ channelId: "C1",
408
+ threadTs: "1700000000.000001",
409
+ timestampTimezone: "America/New_York",
410
+ }),
411
+ }),
412
+ ).toEqual({
413
+ source: "slack",
414
+ conversationExternalId: "C1",
415
+ eventKind: "message",
416
+ threadId: "1700000000.000001",
417
+ timestampTimezone: "America/New_York",
418
+ });
419
+ });
420
+
421
+ test("reads as null for a reconciled row, a non-Slack envelope, and a row without slackMeta", () => {
422
+ expect(
423
+ providerMetadataOfPreSendSlackEnvelope({
424
+ slackMeta: JSON.stringify({
425
+ source: "slack",
426
+ eventKind: "message",
427
+ channelId: "C1",
428
+ channelTs: "1700000000.000002",
429
+ }),
430
+ }),
431
+ ).toBeNull();
432
+ expect(
433
+ providerMetadataOfPreSendSlackEnvelope({
434
+ slackMeta: JSON.stringify({ source: "telegram", channelId: "C1" }),
435
+ }),
436
+ ).toBeNull();
437
+ expect(providerMetadataOfPreSendSlackEnvelope({})).toBeNull();
438
+ });
439
+ });
440
+
441
+ describe("slackViewOfProviderMetadata", () => {
442
+ test("maps a Slack reply row's neutral envelope onto the Slack view, extras included", () => {
443
+ const view = slackViewOfProviderMetadata({
444
+ source: "slack",
445
+ conversationExternalId: "C123",
446
+ messageId: "1700000000.000100",
447
+ threadId: "1700000000.000001",
448
+ eventKind: "message",
449
+ timestampTimezone: "America/New_York",
450
+ timestampTimezoneLabel: "ET",
451
+ slackFiles: [{ name: "report.pdf", mimetype: "application/pdf" }],
452
+ deletedAt: 1700000001000,
453
+ });
454
+ expect(view).toEqual({
455
+ source: "slack",
456
+ channelId: "C123",
457
+ channelTs: "1700000000.000100",
458
+ threadTs: "1700000000.000001",
459
+ eventKind: "message",
460
+ timestampTimezone: "America/New_York",
461
+ timestampTimezoneLabel: "ET",
462
+ slackFiles: [{ name: "report.pdf", mimetype: "application/pdf" }],
463
+ deletedAt: 1700000001000,
464
+ });
465
+ });
466
+
467
+ test("a reaction row's view stores the reacted message's ts as channelTs", () => {
468
+ const view = slackViewOfProviderMetadata({
469
+ source: "slack",
470
+ conversationExternalId: "C123",
471
+ eventKind: "reaction",
472
+ actorExternalId: "U1",
473
+ displayName: "Alice",
474
+ reaction: {
475
+ targetMessageId: "1700000000.000100",
476
+ emoji: "tada",
477
+ op: "added",
478
+ actorDisplayName: "Alice",
479
+ },
480
+ });
481
+ expect(view).toEqual({
482
+ source: "slack",
483
+ channelId: "C123",
484
+ channelTs: "1700000000.000100",
485
+ actorExternalUserId: "U1",
486
+ displayName: "Alice",
487
+ eventKind: "reaction",
488
+ reaction: {
489
+ emoji: "tada",
490
+ op: "added",
491
+ targetChannelTs: "1700000000.000100",
492
+ actorDisplayName: "Alice",
493
+ },
494
+ });
495
+ });
496
+
497
+ test("a row naming no post yet, or another channel's row, has no Slack view", () => {
498
+ expect(
499
+ slackViewOfProviderMetadata({
500
+ source: "slack",
501
+ conversationExternalId: "C123",
502
+ eventKind: "message",
503
+ }),
504
+ ).toBeNull();
505
+ expect(
506
+ slackViewOfProviderMetadata({
507
+ source: "discord",
508
+ conversationExternalId: "999",
509
+ messageId: "1",
510
+ eventKind: "message",
511
+ }),
512
+ ).toBeNull();
513
+ });
514
+
515
+ test("readSlackMetadataFromMessageMetadata serves the neutral envelope before the nested one", () => {
516
+ const metadata = JSON.stringify({
517
+ assistantMessageChannel: "slack",
518
+ providerMeta: JSON.stringify({
519
+ source: "slack",
520
+ conversationExternalId: "C123",
521
+ messageId: "1700000000.000100",
522
+ eventKind: "message",
523
+ }),
524
+ });
525
+ expect(readSlackMetadataFromMessageMetadata(metadata)?.channelTs).toBe(
526
+ "1700000000.000100",
527
+ );
528
+ });
529
+ });
@@ -1,6 +1,14 @@
1
+ import {
2
+ pickReactionEmojiFields,
3
+ ReactionEmojiFieldsSchema,
4
+ } from "@vellumai/service-contracts/reactions";
1
5
  import { z } from "zod";
2
6
 
3
- import type { ProviderMessageMetadata } from "../../provider-message-metadata.js";
7
+ import { safeParseRecord } from "../../../util/json.js";
8
+ import {
9
+ type ProviderMessageMetadata,
10
+ readProviderMessageMetadata,
11
+ } from "../../provider-message-metadata.js";
4
12
 
5
13
  /**
6
14
  * Typed Slack message metadata stored flat in the `messages.metadata` column
@@ -39,6 +47,7 @@ export function isSlackDmConversation(conversationExternalId: string): boolean {
39
47
 
40
48
  const slackReactionMetadataSchema = z.object({
41
49
  emoji: z.string(),
50
+ ...ReactionEmojiFieldsSchema.shape,
42
51
  actorDisplayName: z.string().optional(),
43
52
  targetChannelTs: z.string(),
44
53
  op: z.enum(["added", "removed"]),
@@ -258,6 +267,13 @@ export function readSlackMetadata(
258
267
  return result.success ? result.data : null;
259
268
  }
260
269
 
270
+ /**
271
+ * Read a row's Slack view from its stored metadata, whichever envelope the
272
+ * row carries: the neutral `providerMeta` (every row the daemon authors, and
273
+ * the end state for every Slack row) or the nested `slackMeta` that inbound
274
+ * Slack rows still write. `allowFlatLegacy` also accepts the pre-envelope
275
+ * flat form.
276
+ */
261
277
  export function readSlackMetadataFromMessageMetadata(
262
278
  metadata: string | null | undefined,
263
279
  opts?: { allowFlatLegacy?: boolean },
@@ -279,6 +295,18 @@ export function readSlackMetadataFromMessageMetadata(
279
295
  return null;
280
296
  }
281
297
 
298
+ // A row the daemon authors (an assistant reply, its own reaction, a
299
+ // bot-authored backfill row) describes itself in the neutral envelope;
300
+ // its Slack view is derived from that, so every Slack reader serves both
301
+ // envelopes without a branch of its own.
302
+ const neutral = readProviderMessageMetadata(parent.providerMeta);
303
+ if (neutral !== null) {
304
+ const view = slackViewOfProviderMetadata(neutral);
305
+ if (view !== null) {
306
+ return view;
307
+ }
308
+ }
309
+
282
310
  const nested = parent.slackMeta;
283
311
  if (typeof nested === "string") {
284
312
  const parsedNested = readSlackMetadata(nested);
@@ -290,6 +318,80 @@ export function readSlackMetadataFromMessageMetadata(
290
318
  return opts?.allowFlatLegacy ? readSlackMetadata(metadata) : null;
291
319
  }
292
320
 
321
+ /**
322
+ * Slack's own fields, carried on the neutral envelope's passthrough by rows
323
+ * that write it. They have no neutral counterpart: the Slack transcript
324
+ * renderer is a provider renderer by design and reads them through the
325
+ * Slack view.
326
+ */
327
+ const SLACK_ONLY_FIELDS = [
328
+ "channelName",
329
+ "timestampTimezone",
330
+ "timestampTimezoneLabel",
331
+ "speakerTimezoneLabel",
332
+ "actorTimezone",
333
+ "actorTimezoneLabel",
334
+ "actorTimezoneOffsetSeconds",
335
+ "slackFiles",
336
+ ] as const;
337
+
338
+ /**
339
+ * The Slack view of a neutral envelope: the inverse of
340
+ * `slackMetadataAsProviderMetadata`, for the Slack renderers and the backfill
341
+ * readers. A reaction row's `channelTs` is the reacted message's ts, as the
342
+ * Slack envelope stores it. Null for a non-Slack envelope, and for one that
343
+ * names no post yet (a reply before its post-send reconciliation), which is
344
+ * also what the strict reader says of a pre-send Slack envelope.
345
+ */
346
+ export function slackViewOfProviderMetadata(
347
+ meta: ProviderMessageMetadata,
348
+ ): SlackMessageMetadata | null {
349
+ if (meta.source !== "slack") {
350
+ return null;
351
+ }
352
+ const channelTs =
353
+ meta.eventKind === "reaction"
354
+ ? meta.reaction?.targetMessageId
355
+ : meta.messageId;
356
+ if (channelTs === undefined) {
357
+ return null;
358
+ }
359
+ const candidate: Record<string, unknown> = {
360
+ source: "slack",
361
+ channelId: meta.conversationExternalId,
362
+ channelTs,
363
+ ...(meta.threadId !== undefined ? { threadTs: meta.threadId } : {}),
364
+ ...(meta.displayName !== undefined
365
+ ? { displayName: meta.displayName }
366
+ : {}),
367
+ ...(meta.actorExternalId !== undefined
368
+ ? { actorExternalUserId: meta.actorExternalId }
369
+ : {}),
370
+ eventKind: meta.eventKind,
371
+ ...(meta.reaction
372
+ ? {
373
+ reaction: {
374
+ emoji: meta.reaction.emoji,
375
+ op: meta.reaction.op,
376
+ targetChannelTs: meta.reaction.targetMessageId,
377
+ ...(meta.reaction.actorDisplayName
378
+ ? { actorDisplayName: meta.reaction.actorDisplayName }
379
+ : {}),
380
+ },
381
+ }
382
+ : {}),
383
+ ...(meta.editedAt !== undefined ? { editedAt: meta.editedAt } : {}),
384
+ ...(meta.deletedAt !== undefined ? { deletedAt: meta.deletedAt } : {}),
385
+ };
386
+ for (const field of SLACK_ONLY_FIELDS) {
387
+ if (meta[field] !== undefined) {
388
+ candidate[field] = meta[field];
389
+ }
390
+ }
391
+ const result = slackMessageMetadataSchema.safeParse(candidate);
392
+ return result.success ? result.data : null;
393
+ }
394
+
293
395
  /**
294
396
  * Present a Slack row's stored metadata as the channel-neutral shape.
295
397
  *
@@ -321,6 +423,7 @@ export function slackMetadataAsProviderMetadata(
321
423
  reaction: {
322
424
  targetMessageId: meta.reaction.targetChannelTs,
323
425
  emoji: meta.reaction.emoji,
426
+ ...pickReactionEmojiFields(meta.reaction),
324
427
  op: meta.reaction.op,
325
428
  ...(meta.reaction.actorDisplayName
326
429
  ? { actorDisplayName: meta.reaction.actorDisplayName }
@@ -333,6 +436,43 @@ export function slackMetadataAsProviderMetadata(
333
436
  };
334
437
  }
335
438
 
439
+ /**
440
+ * The neutral envelope of a reply row reserved with Slack's own pre-send
441
+ * envelope: a `slackMeta` naming the channel and thread but no `channelTs`,
442
+ * the id the post-send reconciliation fills in.
443
+ *
444
+ * Transitional: a reply the daemon reserves carries the neutral envelope from
445
+ * the start, so this serves only a reply still pending for the retry sweep
446
+ * that a daemon reserving Slack replies under `slackMeta` left there; the
447
+ * reconciliation stamps it and converges the row onto the neutral envelope,
448
+ * Slack's own fields riding the schema's passthrough. Delete once no such
449
+ * row can be pending. A `slackMeta` that already names its post is a
450
+ * reconciled row and reads as null here; `readSlackMetadata` serves it.
451
+ */
452
+ export function providerMetadataOfPreSendSlackEnvelope(
453
+ envelope: Record<string, unknown>,
454
+ ): ProviderMessageMetadata | null {
455
+ if (typeof envelope.slackMeta !== "string") {
456
+ return null;
457
+ }
458
+ const { source, channelId, channelTs, threadTs, ...slackFields } =
459
+ safeParseRecord(envelope.slackMeta);
460
+ if (
461
+ source !== "slack" ||
462
+ typeof channelId !== "string" ||
463
+ channelTs !== undefined
464
+ ) {
465
+ return null;
466
+ }
467
+ return {
468
+ ...slackFields,
469
+ source: "slack",
470
+ conversationExternalId: channelId,
471
+ eventKind: "message",
472
+ ...(typeof threadTs === "string" ? { threadId: threadTs } : {}),
473
+ };
474
+ }
475
+
336
476
  /**
337
477
  * Serialize `SlackMessageMetadata` to a JSON string suitable for a fresh
338
478
  * write to the `messages.metadata` column. Use `mergeSlackMetadata` when an
@@ -10,7 +10,13 @@ type CallSlackApi = typeof import("./api.js").callSlackApi;
10
10
 
11
11
  const callSlackApiMock = mock<CallSlackApi>(async () => ({ ok: true }));
12
12
 
13
+ // Spread the real module so a factory listing only what today's tests touch
14
+ // cannot break the next import send.ts adds; the stubs below then override
15
+ // exactly the calls this suite asserts on.
16
+ const actualSlackApi = await import("./api.js");
17
+
13
18
  mock.module("./api.js", () => ({
19
+ ...actualSlackApi,
14
20
  callSlackApi: (method: string, body: Record<string, unknown>) =>
15
21
  callSlackApiMock(method, body),
16
22
  callSlackApiForm: async () => ({}),
@@ -32,8 +38,10 @@ const {
32
38
  sendSlackAgentSessionStatus,
33
39
  sendSlackReaction,
34
40
  sendSlackReply,
41
+ sendSlackStreamOp,
35
42
  updateSlackMessage,
36
43
  } = await import("./send.js");
44
+ const { SLACK_STREAM_MARKDOWN_LIMIT } = await import("./api.js");
37
45
 
38
46
  describe("sendSlackAgentSessionStatus", () => {
39
47
  const threadTs = "1700000000.000100";
@@ -442,3 +450,53 @@ describe("sendSlackReply approval fallback", () => {
442
450
  expect(callSlackApiMock).toHaveBeenCalledTimes(1);
443
451
  });
444
452
  });
453
+
454
+ describe("sendSlackStreamOp", () => {
455
+ const streamTs = "1700000000.000900";
456
+
457
+ beforeEach(() => {
458
+ callSlackApiMock.mockReset();
459
+ callSlackApiMock.mockImplementation(async () => ({ ok: true }));
460
+ });
461
+
462
+ test("sends one call per operation, whatever the caller hands over", async () => {
463
+ // Splitting to fit the cap belongs to the caller, which is what tracks how
464
+ // much of the reply Slack has taken. This layer performs the operation it
465
+ // is given, once.
466
+ const appended = "x".repeat(SLACK_STREAM_MARKDOWN_LIMIT);
467
+ await sendSlackStreamOp("C-STREAM", {
468
+ action: "append",
469
+ streamId: streamTs,
470
+ text: appended,
471
+ appended,
472
+ });
473
+
474
+ const calls = callSlackApiMock.mock.calls.filter(
475
+ (call) => call[0] === "chat.appendStream",
476
+ );
477
+ expect(calls).toHaveLength(1);
478
+ expect((calls[0]![1] as { markdownText?: string }).markdownText).toBe(
479
+ appended,
480
+ );
481
+ });
482
+
483
+ test("a plan that moved with no new words still reaches the message", async () => {
484
+ // `chat.appendStream` documents "One of markdown_text or chunks is
485
+ // required", so a plan-only call is legal and is what ticks the plan block
486
+ // during silent work.
487
+ await sendSlackStreamOp("C-STREAM", {
488
+ action: "append",
489
+ streamId: streamTs,
490
+ text: "unchanged",
491
+ plan: { steps: [{ label: "Step", status: "completed" }] },
492
+ });
493
+
494
+ const calls = callSlackApiMock.mock.calls.filter(
495
+ (call) => call[0] === "chat.appendStream",
496
+ );
497
+ expect(calls).toHaveLength(1);
498
+ const body = calls[0]![1] as { markdownText?: string; tasks?: unknown };
499
+ expect(body.markdownText).toBeUndefined();
500
+ expect(body.tasks).toBeDefined();
501
+ });
502
+ });
@@ -331,10 +331,10 @@ export async function sendSlackStreamOp(
331
331
  channel,
332
332
  threadTs: op.anchorMessageId,
333
333
  markdownText: op.appended ?? op.text,
334
- // Fixed for the stream's lifetime at start, while a plan usually
335
- // arrives after the first text flush has opened it. It only affects
336
- // how task chunks render, so a stream that never carries a plan still
337
- // reads as a plain message.
334
+ // Fixed for the stream's lifetime at start, so it is set
335
+ // unconditionally: a plan that first appears on a later append still
336
+ // renders as a plan. It only affects how task chunks render, so a
337
+ // stream that never carries one still reads as a plain message.
338
338
  taskDisplayMode: "plan",
339
339
  planTitle,
340
340
  tasks,