@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
@@ -1,5 +1,6 @@
1
1
  import { beforeEach, describe, expect, it, mock } from "bun:test";
2
2
 
3
+ import { REACTION_MESSAGE_KIND } from "../persistence/conversation-types.js";
3
4
  import { resolveMessageContentBlocks } from "../persistence/message-content-file.js";
4
5
  import type { RuntimeAttachmentMetadata } from "../runtime/http-types.js";
5
6
 
@@ -32,8 +33,17 @@ type UpdateMessageMetadataCall = {
32
33
  };
33
34
  const updateMessageMetadataCalls: UpdateMessageMetadataCall[] = [];
34
35
 
36
+ /**
37
+ * Number of leading `updateMessageMetadata` calls that throw a transient
38
+ * SQLite error before the mock starts writing. Lets a test assert that a lost
39
+ * reconciliation write is recovered rather than forfeited.
40
+ */
41
+ let metadataWriteFailuresRemaining = 0;
42
+
35
43
  /** Per-test override for the synthetic Slack `ts` returned by deliverChannelReply. */
36
44
  let nextDeliveryTs: string | null = null;
45
+ /** Per-segment ts values, consumed in order before `nextDeliveryTs`. */
46
+ const deliveryTsQueue: string[] = [];
37
47
 
38
48
  type RenderedHistoryStub = {
39
49
  text: string;
@@ -83,12 +93,12 @@ mock.module("../runtime/gateway-client.js", () => ({
83
93
  throw new Error("Simulated delivery failure (502)");
84
94
  }
85
95
  deliveryCalls.push({ callbackUrl, payload });
96
+ const queued = deliveryTsQueue.shift();
97
+ if (queued !== undefined) {
98
+ return { ok: true, ts: queued };
99
+ }
86
100
  if (nextDeliveryTs !== null) {
87
- const ts = nextDeliveryTs;
88
- // Only the first segment of a multi-segment delivery should carry
89
- // back a meaningful ts for `channelTs` reconciliation. Tests that
90
- // need specific ts values per-segment can re-set this between calls.
91
- return { ok: true, ts };
101
+ return { ok: true, ts: nextDeliveryTs };
92
102
  }
93
103
  return { ok: true };
94
104
  },
@@ -141,10 +151,31 @@ mock.module("../persistence/conversation-crud.js", () => ({
141
151
  },
142
152
  getMessageById: (messageId: string) =>
143
153
  conversationMessages.find((m) => m.id === messageId) ?? null,
154
+ // Stands in for the schema-validated parse. The delivery reader pairs it
155
+ // with the real `isReactionMessageMetadata`, so the marker the assertions
156
+ // turn on is still the production predicate reading a production-shaped row.
157
+ parseMessageMetadata: (metadataJson: string | null) => {
158
+ if (!metadataJson) {
159
+ return undefined;
160
+ }
161
+ try {
162
+ return JSON.parse(metadataJson) as Record<string, unknown>;
163
+ } catch {
164
+ return undefined;
165
+ }
166
+ },
144
167
  updateMessageMetadata: (
145
168
  messageId: string,
146
169
  updates: Record<string, unknown>,
147
170
  ) => {
171
+ if (metadataWriteFailuresRemaining > 0) {
172
+ metadataWriteFailuresRemaining -= 1;
173
+ // Shaped like bun:sqlite's error so `withSqliteRetry` classifies it as
174
+ // transient contention rather than a fatal write.
175
+ throw Object.assign(new Error("database is locked"), {
176
+ code: "SQLITE_BUSY",
177
+ });
178
+ }
148
179
  updateMessageMetadataCalls.push({ messageId, updates });
149
180
  const row = conversationMessages.find((m) => m.id === messageId);
150
181
  if (!row) {
@@ -159,15 +190,43 @@ mock.module("../persistence/conversation-crud.js", () => ({
159
190
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
160
191
  }));
161
192
 
193
+ // The reconciler writes the outbound-posts index through delivery-crud;
194
+ // stub it so this suite stays DB-free while capturing the writes.
195
+ const recordedOutboundPosts: Array<Record<string, string>> = [];
196
+ /** Runs after each index write: a seam to observe or interleave a deletion before the envelope stamp. */
197
+ let onRecordOutboundPost: ((post: Record<string, string>) => void) | null =
198
+ null;
199
+ mock.module("../persistence/delivery-crud.js", () => ({
200
+ recordOutboundPost: (post: Record<string, string>) => {
201
+ recordedOutboundPosts.push(post);
202
+ onRecordOutboundPost?.(post);
203
+ },
204
+ }));
205
+
162
206
  mock.module("../persistence/attachments-store.js", () => ({
163
207
  getAttachmentMetadataForMessage: (messageId: string) =>
164
208
  attachmentsByMessageId.get(messageId) ?? [],
165
209
  getFilePathForAttachment: () => null,
166
210
  }));
167
211
 
212
+ /**
213
+ * Renders keyed by a row's raw string content, consulted ahead of the queue.
214
+ * The queue and the shared default are argument-blind, so a suite that mixes
215
+ * rows in one scan cannot otherwise tell them apart, and a row the code under
216
+ * test is supposed to skip would still render as its neighbour.
217
+ */
218
+ const renderedHistoryContentByContent = new Map<string, RenderedHistoryStub>();
219
+
168
220
  mock.module("../daemon/handlers/shared.js", () => ({
169
- renderHistoryContent: () =>
170
- renderedHistoryContentQueue.shift() ?? renderedHistoryContent,
221
+ renderHistoryContent: (content: unknown) => {
222
+ if (typeof content === "string") {
223
+ const keyed = renderedHistoryContentByContent.get(content);
224
+ if (keyed) {
225
+ return keyed;
226
+ }
227
+ }
228
+ return renderedHistoryContentQueue.shift() ?? renderedHistoryContent;
229
+ },
171
230
  }));
172
231
 
173
232
  const {
@@ -183,8 +242,13 @@ describe("channel-reply-delivery", () => {
183
242
  conversationMessages.length = 0;
184
243
  attachmentsByMessageId.clear();
185
244
  updateMessageMetadataCalls.length = 0;
245
+ metadataWriteFailuresRemaining = 0;
186
246
  nextDeliveryTs = null;
247
+ deliveryTsQueue.length = 0;
248
+ recordedOutboundPosts.length = 0;
249
+ onRecordOutboundPost = null;
187
250
  renderedHistoryContentQueue.length = 0;
251
+ renderedHistoryContentByContent.clear();
188
252
  renderedHistoryContent = {
189
253
  text: "",
190
254
  textSegments: [],
@@ -239,6 +303,129 @@ describe("channel-reply-delivery", () => {
239
303
  );
240
304
  });
241
305
 
306
+ describe("reaction rows are never a turn's reply", () => {
307
+ /** The body `persistReactionRecords` stores; never channel-visible text. */
308
+ const SENTINEL = "[reaction]";
309
+
310
+ const stubFor = (text: string): RenderedHistoryStub => ({
311
+ text,
312
+ textSegments: [text],
313
+ toolCalls: [],
314
+ toolCallsBeforeText: false,
315
+ contentOrder: ["text:0"],
316
+ surfaces: [],
317
+ thinkingSegments: [],
318
+ });
319
+
320
+ /**
321
+ * The row `persistReactionRecords` writes at the turn boundary, rendering
322
+ * as the stored sentinel exactly as the real renderer would. Read
323
+ * literally that text is what reaches the channel, so these tests fail on
324
+ * a guard that only stops short of the delivery call.
325
+ */
326
+ const reactionRow = (id: string): MockMessageRow => {
327
+ renderedHistoryContentByContent.set(SENTINEL, stubFor(SENTINEL));
328
+ return {
329
+ id,
330
+ role: "assistant",
331
+ content: SENTINEL,
332
+ metadata: JSON.stringify({
333
+ messageKind: REACTION_MESSAGE_KIND,
334
+ provenanceSourceChannel: "slack",
335
+ }),
336
+ };
337
+ };
338
+
339
+ /** How the turn's one non-reaction assistant row renders. */
340
+ const renderAs = (text: string): void => {
341
+ renderedHistoryContent = stubFor(text);
342
+ };
343
+
344
+ it("resolves a reaction-only turn to its silence marker, not the sentinel row", () => {
345
+ conversationMessages.push(
346
+ { id: "user-target", role: "user", content: "target" },
347
+ {
348
+ id: "assistant-silence",
349
+ role: "assistant",
350
+ content: "<no_response/>",
351
+ },
352
+ // Drained after the turn's own rows, so it is the newest row and the
353
+ // one a newest-first scan reaches first.
354
+ reactionRow("assistant-reaction"),
355
+ );
356
+ renderAs("<no_response/>");
357
+
358
+ expect(findAssistantReplyMessageIdForTurn("conv-1", "user-target")).toBe(
359
+ "assistant-silence",
360
+ );
361
+ });
362
+
363
+ it("posts nothing at all for a reaction-only turn", async () => {
364
+ conversationMessages.push(
365
+ { id: "user-target", role: "user", content: "target" },
366
+ {
367
+ id: "assistant-silence",
368
+ role: "assistant",
369
+ content: "<no_response/>",
370
+ },
371
+ reactionRow("assistant-reaction"),
372
+ );
373
+ renderAs("<no_response/>");
374
+
375
+ await deliverReplyViaCallback(
376
+ "conv-1",
377
+ "chat-1",
378
+ "http://gateway/deliver/slack",
379
+ "assistant-1",
380
+ { messageId: "assistant-silence", sinceMessageId: "user-target" },
381
+ );
382
+
383
+ expect(deliveryCalls).toEqual([]);
384
+ });
385
+
386
+ it("delivers the turn's real reply when a reaction row is newer than it", async () => {
387
+ conversationMessages.push(
388
+ { id: "user-target", role: "user", content: "target" },
389
+ { id: "assistant-real", role: "assistant", content: "real reply" },
390
+ reactionRow("assistant-reaction"),
391
+ );
392
+ renderAs("Done.");
393
+
394
+ await deliverReplyViaCallback(
395
+ "conv-1",
396
+ "chat-1",
397
+ "http://gateway/deliver/slack",
398
+ "assistant-1",
399
+ { sinceMessageId: "user-target" },
400
+ );
401
+
402
+ expect(deliveryCalls).toHaveLength(1);
403
+ expect(deliveryCalls[0].payload.text).toBe("Done.");
404
+ });
405
+
406
+ it("falls through to the real reply when the stored reply id names a reaction row", async () => {
407
+ // The retry sweep persists whatever the turn scan returned, so a reply
408
+ // id latched before this guard existed still points at a reaction row.
409
+ conversationMessages.push(
410
+ { id: "user-target", role: "user", content: "target" },
411
+ { id: "assistant-real", role: "assistant", content: "real reply" },
412
+ reactionRow("assistant-reaction"),
413
+ );
414
+ renderAs("Done.");
415
+
416
+ await deliverReplyViaCallback(
417
+ "conv-1",
418
+ "chat-1",
419
+ "http://gateway/deliver/slack",
420
+ "assistant-1",
421
+ { messageId: "assistant-reaction", sinceMessageId: "user-target" },
422
+ );
423
+
424
+ expect(deliveryCalls).toHaveLength(1);
425
+ expect(deliveryCalls[0].payload.text).toBe("Done.");
426
+ });
427
+ });
428
+
242
429
  it("sends non-empty text segments as separate messages and puts attachments on the last segment", async () => {
243
430
  const attachments: RuntimeAttachmentMetadata[] = [
244
431
  {
@@ -787,7 +974,9 @@ describe("channel-reply-delivery", () => {
787
974
  attachments,
788
975
  startFromSegment: 1,
789
976
  messageTs: "1700000000.000055",
790
- onMessageTs: (ts) => seenTs.push(ts),
977
+ onMessageTs: (ts) => {
978
+ seenTs.push(ts);
979
+ },
791
980
  });
792
981
 
793
982
  expect(deliveryCalls).toHaveLength(1);
@@ -1047,24 +1236,24 @@ describe("channel-reply-delivery", () => {
1047
1236
  // reconciliation every outbound assistant row falls through to the
1048
1237
  // legacy/flat fallback and is excluded from thread-tag rendering and the
1049
1238
  // active-thread focus block.
1050
- describe("slackMeta.channelTs reconciliation", () => {
1051
- /** Build the outer envelope mirroring `handleMessageComplete`'s write. */
1052
- function partialSlackEnvelope(
1053
- channelId: string,
1054
- threadTs?: string,
1055
- ): string {
1056
- // Note: this matches the partial write — channelTs is intentionally
1057
- // absent so `readSlackMetadata` returns null until reconciliation runs.
1058
- const inner: Record<string, unknown> = {
1059
- source: "slack",
1060
- eventKind: "message",
1061
- channelId,
1062
- ...(threadTs ? { threadTs } : {}),
1063
- };
1239
+ describe("sent-message-id reconciliation", () => {
1240
+ /**
1241
+ * Build the outer envelope mirroring `buildAssistantChannelMetadata`'s
1242
+ * write for a Slack reply: the neutral envelope, Slack's own fields on
1243
+ * its passthrough, and no `messageId` yet, since the row is written
1244
+ * before the post.
1245
+ */
1246
+ function partialEnvelope(channelId: string, threadId?: string): string {
1064
1247
  return JSON.stringify({
1065
1248
  userMessageChannel: "slack",
1066
1249
  assistantMessageChannel: "slack",
1067
- slackMeta: JSON.stringify(inner),
1250
+ providerMeta: JSON.stringify({
1251
+ source: "slack",
1252
+ conversationExternalId: channelId,
1253
+ eventKind: "message",
1254
+ ...(threadId ? { threadId } : {}),
1255
+ timestampTimezone: "America/New_York",
1256
+ }),
1068
1257
  });
1069
1258
  }
1070
1259
 
@@ -1072,15 +1261,15 @@ describe("channel-reply-delivery", () => {
1072
1261
  conversationId: string,
1073
1262
  messageId: string,
1074
1263
  channelId: string,
1075
- threadTs?: string,
1264
+ threadId?: string,
1076
1265
  ): void {
1077
1266
  conversationMessages.push({
1078
1267
  id: messageId,
1079
1268
  role: "assistant",
1080
1269
  content: '[{"type":"text","text":"hello"}]',
1081
- metadata: partialSlackEnvelope(channelId, threadTs),
1270
+ metadata: partialEnvelope(channelId, threadId),
1082
1271
  });
1083
- // Set up renderer to produce one segment so onMessageTs fires once.
1272
+ // One segment, so onMessageTs fires once.
1084
1273
  renderedHistoryContent = {
1085
1274
  text: "hello",
1086
1275
  textSegments: ["hello"],
@@ -1092,7 +1281,25 @@ describe("channel-reply-delivery", () => {
1092
1281
  };
1093
1282
  }
1094
1283
 
1095
- it("writes channelTs into slackMeta from the gateway-returned ts (top-level reply)", async () => {
1284
+ function twoSegments(): void {
1285
+ renderedHistoryContent = {
1286
+ text: "AlphaBeta",
1287
+ textSegments: ["Alpha", "Beta"],
1288
+ toolCalls: [],
1289
+ toolCallsBeforeText: false,
1290
+ contentOrder: ["text:0", "tool:0", "text:1"],
1291
+ surfaces: [],
1292
+ thinkingSegments: [],
1293
+ };
1294
+ }
1295
+
1296
+ function envelopeOf(messageId: string): Record<string, unknown> {
1297
+ const row = conversationMessages.find((m) => m.id === messageId);
1298
+ const outer = JSON.parse(row?.metadata ?? "{}") as Record<string, string>;
1299
+ return JSON.parse(outer.providerMeta) as Record<string, unknown>;
1300
+ }
1301
+
1302
+ it("writes the gateway-returned ts as the row's messageId (top-level reply)", async () => {
1096
1303
  pushPartialAssistantRow("conv-recon-top", "msg-recon-top", "C123");
1097
1304
  nextDeliveryTs = "1700000123.000456";
1098
1305
 
@@ -1104,19 +1311,25 @@ describe("channel-reply-delivery", () => {
1104
1311
  );
1105
1312
 
1106
1313
  expect(updateMessageMetadataCalls.length).toBe(1);
1107
- const call = updateMessageMetadataCalls[0];
1108
- expect(call.messageId).toBe("msg-recon-top");
1109
- const merged = call.updates.slackMeta as string;
1110
- expect(typeof merged).toBe("string");
1111
- const parsed = JSON.parse(merged) as Record<string, unknown>;
1112
- expect(parsed.source).toBe("slack");
1113
- expect(parsed.channelId).toBe("C123");
1114
- expect(parsed.eventKind).toBe("message");
1115
- expect(parsed.channelTs).toBe("1700000123.000456");
1116
- expect(parsed.threadTs).toBeUndefined();
1314
+ expect(updateMessageMetadataCalls[0].messageId).toBe("msg-recon-top");
1315
+ const envelope = envelopeOf("msg-recon-top");
1316
+ expect(envelope.source).toBe("slack");
1317
+ expect(envelope.conversationExternalId).toBe("C123");
1318
+ expect(envelope.eventKind).toBe("message");
1319
+ expect(envelope.messageId).toBe("1700000123.000456");
1320
+ expect(envelope.threadId).toBeUndefined();
1321
+ // Slack's own field survived the stamp.
1322
+ expect(envelope.timestampTimezone).toBe("America/New_York");
1323
+ expect(recordedOutboundPosts).toHaveLength(1);
1324
+ expect(recordedOutboundPosts[0]).toMatchObject({
1325
+ sourceChannel: "slack",
1326
+ externalChatId: "C123",
1327
+ providerMessageId: "1700000123.000456",
1328
+ messageId: "msg-recon-top",
1329
+ });
1117
1330
  });
1118
1331
 
1119
- it("preserves an existing threadTs when reconciling channelTs (threaded reply)", async () => {
1332
+ it("preserves an existing threadId when stamping the id (threaded reply)", async () => {
1120
1333
  pushPartialAssistantRow(
1121
1334
  "conv-recon-thread",
1122
1335
  "msg-recon-thread",
@@ -1132,16 +1345,14 @@ describe("channel-reply-delivery", () => {
1132
1345
  "assistant-recon-thread",
1133
1346
  );
1134
1347
 
1135
- expect(updateMessageMetadataCalls.length).toBe(1);
1136
- const merged = updateMessageMetadataCalls[0].updates.slackMeta as string;
1137
- const parsed = JSON.parse(merged) as Record<string, unknown>;
1138
- expect(parsed.threadTs).toBe("1234.5678");
1139
- expect(parsed.channelTs).toBe("1700000200.000700");
1348
+ const envelope = envelopeOf("msg-recon-thread");
1349
+ expect(envelope.threadId).toBe("1234.5678");
1350
+ expect(envelope.messageId).toBe("1700000200.000700");
1140
1351
  });
1141
1352
 
1142
- it("does NOT call updateMessageMetadata when the assistant row has no slackMeta", async () => {
1143
- // vellum/telegram/non-slack outbound: the row's metadata envelope has
1144
- // no slackMeta sub-key. The reconciler must short-circuit silently.
1353
+ it("does NOT call updateMessageMetadata when the assistant row has no envelope", async () => {
1354
+ // vellum outbound: the row's metadata carries no provider envelope.
1355
+ // The reconciler must short-circuit silently.
1145
1356
  conversationMessages.push({
1146
1357
  id: "msg-vellum",
1147
1358
  role: "assistant",
@@ -1170,17 +1381,12 @@ describe("channel-reply-delivery", () => {
1170
1381
  );
1171
1382
 
1172
1383
  expect(updateMessageMetadataCalls.length).toBe(0);
1384
+ expect(recordedOutboundPosts.length).toBe(0);
1173
1385
  });
1174
1386
 
1175
- it("does NOT call updateMessageMetadata when slackMeta already has channelTs", async () => {
1176
- // Idempotency: a re-delivery (e.g. from channel-retry-sweep) must not
1177
- // overwrite a channelTs that is already in place.
1178
- const existingMeta = JSON.stringify({
1179
- source: "slack",
1180
- eventKind: "message",
1181
- channelId: "C789",
1182
- channelTs: "1699999999.000111",
1183
- });
1387
+ it("does NOT write again when the row already names the ts", async () => {
1388
+ // Idempotency: a redelivery of the same message must not write the
1389
+ // row or the index again.
1184
1390
  conversationMessages.push({
1185
1391
  id: "msg-already",
1186
1392
  role: "assistant",
@@ -1188,7 +1394,12 @@ describe("channel-reply-delivery", () => {
1188
1394
  metadata: JSON.stringify({
1189
1395
  userMessageChannel: "slack",
1190
1396
  assistantMessageChannel: "slack",
1191
- slackMeta: existingMeta,
1397
+ providerMeta: JSON.stringify({
1398
+ source: "slack",
1399
+ conversationExternalId: "C789",
1400
+ eventKind: "message",
1401
+ messageId: "1699999999.000111",
1402
+ }),
1192
1403
  }),
1193
1404
  });
1194
1405
  renderedHistoryContent = {
@@ -1200,7 +1411,7 @@ describe("channel-reply-delivery", () => {
1200
1411
  surfaces: [],
1201
1412
  thinkingSegments: [],
1202
1413
  };
1203
- nextDeliveryTs = "1700000400.000999";
1414
+ nextDeliveryTs = "1699999999.000111";
1204
1415
 
1205
1416
  await deliverReplyViaCallback(
1206
1417
  "conv-already",
@@ -1210,23 +1421,125 @@ describe("channel-reply-delivery", () => {
1210
1421
  );
1211
1422
 
1212
1423
  expect(updateMessageMetadataCalls.length).toBe(0);
1424
+ expect(recordedOutboundPosts.length).toBe(0);
1213
1425
  });
1214
1426
 
1215
- it("only reconciles from the FIRST segment's ts when the reply is split", async () => {
1216
- pushPartialAssistantRow("conv-multi", "msg-multi", "C999");
1217
- // Two-segment delivery: only the first segment's ts is the canonical
1218
- // channelTs for the persisted row. Subsequent segments correspond to
1219
- // independent Slack messages.
1427
+ it("stamps a reply reserved with Slack's pre-send envelope and converges it onto the neutral one", async () => {
1428
+ // Transitional: such a row is pending for the retry sweep only when a
1429
+ // daemon that reserved Slack replies under `slackMeta` left it there.
1430
+ conversationMessages.push({
1431
+ id: "msg-presend-slack",
1432
+ role: "assistant",
1433
+ content: '[{"type":"text","text":"hello"}]',
1434
+ metadata: JSON.stringify({
1435
+ userMessageChannel: "slack",
1436
+ assistantMessageChannel: "slack",
1437
+ slackMeta: JSON.stringify({
1438
+ source: "slack",
1439
+ eventKind: "message",
1440
+ channelId: "C321",
1441
+ threadTs: "1700000000.000001",
1442
+ timestampTimezone: "America/New_York",
1443
+ timestampTimezoneLabel: "EST",
1444
+ }),
1445
+ }),
1446
+ });
1220
1447
  renderedHistoryContent = {
1221
- text: "AlphaBeta",
1222
- textSegments: ["Alpha", "Beta"],
1448
+ text: "hello",
1449
+ textSegments: ["hello"],
1223
1450
  toolCalls: [],
1224
1451
  toolCallsBeforeText: false,
1225
- contentOrder: ["text:0", "tool:0", "text:1"],
1452
+ contentOrder: ["text:0"],
1453
+ surfaces: [],
1454
+ thinkingSegments: [],
1455
+ };
1456
+ nextDeliveryTs = "1700000321.000111";
1457
+
1458
+ await deliverReplyViaCallback(
1459
+ "conv-presend",
1460
+ "C321",
1461
+ "http://gateway/deliver/slack",
1462
+ "assistant-presend",
1463
+ );
1464
+
1465
+ expect(recordedOutboundPosts).toHaveLength(1);
1466
+ expect(recordedOutboundPosts[0]).toMatchObject({
1467
+ sourceChannel: "slack",
1468
+ externalChatId: "C321",
1469
+ providerMessageId: "1700000321.000111",
1470
+ messageId: "msg-presend-slack",
1471
+ });
1472
+ const row = conversationMessages.find(
1473
+ (m) => m.id === "msg-presend-slack",
1474
+ );
1475
+ const outer = JSON.parse(row?.metadata ?? "{}") as Record<
1476
+ string,
1477
+ unknown
1478
+ >;
1479
+ // One envelope per row: the pre-send Slack one is gone, the turn's
1480
+ // other keys survive.
1481
+ expect(outer.slackMeta).toBeUndefined();
1482
+ expect(outer.userMessageChannel).toBe("slack");
1483
+ expect(envelopeOf("msg-presend-slack")).toEqual({
1484
+ source: "slack",
1485
+ conversationExternalId: "C321",
1486
+ eventKind: "message",
1487
+ threadId: "1700000000.000001",
1488
+ messageId: "1700000321.000111",
1489
+ timestampTimezone: "America/New_York",
1490
+ timestampTimezoneLabel: "EST",
1491
+ });
1492
+ const { readSlackMetadataFromMessageMetadata } =
1493
+ await import("../messaging/providers/slack/message-metadata.js");
1494
+ expect(readSlackMetadataFromMessageMetadata(row?.metadata)).toMatchObject(
1495
+ {
1496
+ channelTs: "1700000321.000111",
1497
+ threadTs: "1700000000.000001",
1498
+ timestampTimezoneLabel: "EST",
1499
+ },
1500
+ );
1501
+ });
1502
+
1503
+ it("leaves a Slack reply that already names its post under slackMeta alone", async () => {
1504
+ conversationMessages.push({
1505
+ id: "msg-slack-complete",
1506
+ role: "assistant",
1507
+ content: '[{"type":"text","text":"hello"}]',
1508
+ metadata: JSON.stringify({
1509
+ slackMeta: JSON.stringify({
1510
+ source: "slack",
1511
+ eventKind: "message",
1512
+ channelId: "C321",
1513
+ channelTs: "1700000100.000001",
1514
+ }),
1515
+ }),
1516
+ });
1517
+ renderedHistoryContent = {
1518
+ text: "hello",
1519
+ textSegments: ["hello"],
1520
+ toolCalls: [],
1521
+ toolCallsBeforeText: false,
1522
+ contentOrder: ["text:0"],
1226
1523
  surfaces: [],
1227
1524
  thinkingSegments: [],
1228
1525
  };
1229
- nextDeliveryTs = "1700000500.000111";
1526
+ nextDeliveryTs = "1700000321.000222";
1527
+
1528
+ await deliverReplyViaCallback(
1529
+ "conv-complete",
1530
+ "C321",
1531
+ "http://gateway/deliver/slack",
1532
+ "assistant-complete",
1533
+ );
1534
+
1535
+ expect(updateMessageMetadataCalls.length).toBe(0);
1536
+ expect(recordedOutboundPosts.length).toBe(0);
1537
+ });
1538
+
1539
+ it("records every segment of a split reply: the first as messageId, the rest as additional posts, all in the index", async () => {
1540
+ pushPartialAssistantRow("conv-multi", "msg-multi", "C999");
1541
+ twoSegments();
1542
+ deliveryTsQueue.push("1700000500.000111", "1700000500.000222");
1230
1543
 
1231
1544
  await deliverReplyViaCallback(
1232
1545
  "conv-multi",
@@ -1235,12 +1548,14 @@ describe("channel-reply-delivery", () => {
1235
1548
  "assistant-multi",
1236
1549
  );
1237
1550
 
1238
- // Two delivery POSTs but only one metadata write — the first ts wins.
1239
1551
  expect(deliveryCalls.length).toBe(2);
1240
- expect(updateMessageMetadataCalls.length).toBe(1);
1241
- const merged = updateMessageMetadataCalls[0].updates.slackMeta as string;
1242
- const parsed = JSON.parse(merged) as Record<string, unknown>;
1243
- expect(parsed.channelTs).toBe("1700000500.000111");
1552
+ expect(updateMessageMetadataCalls.length).toBe(2);
1553
+ const envelope = envelopeOf("msg-multi");
1554
+ expect(envelope.messageId).toBe("1700000500.000111");
1555
+ expect(envelope.additionalMessageIds).toEqual(["1700000500.000222"]);
1556
+ expect(
1557
+ recordedOutboundPosts.map((post) => post.providerMessageId),
1558
+ ).toEqual(["1700000500.000111", "1700000500.000222"]);
1244
1559
  });
1245
1560
 
1246
1561
  it("composes with caller-supplied onMessageTs without losing either side-effect", async () => {
@@ -1254,24 +1569,24 @@ describe("channel-reply-delivery", () => {
1254
1569
  "http://gateway/deliver/slack",
1255
1570
  "assistant-compose",
1256
1571
  {
1257
- onMessageTs: (ts) => callerTsSeen.push(ts),
1572
+ onMessageTs: (ts) => {
1573
+ callerTsSeen.push(ts);
1574
+ },
1258
1575
  },
1259
1576
  );
1260
1577
 
1261
- // Caller's onMessageTs still fires for the delivered segment.
1262
1578
  expect(callerTsSeen).toEqual(["1700000600.000222"]);
1263
- // And reconciliation still wrote channelTs.
1264
- expect(updateMessageMetadataCalls.length).toBe(1);
1265
- const merged = updateMessageMetadataCalls[0].updates.slackMeta as string;
1266
- const parsed = JSON.parse(merged) as Record<string, unknown>;
1267
- expect(parsed.channelTs).toBe("1700000600.000222");
1579
+ expect(envelopeOf("msg-compose").messageId).toBe("1700000600.000222");
1268
1580
  });
1269
1581
 
1270
- it("after reconciliation, readSlackMetadata returns a valid envelope", async () => {
1271
- // End-to-end: this is the assertion that the ORIGINAL gap test
1272
- // (`outbound-slack-persistence.test.ts:209`) was inverted on. Once
1273
- // reconciliation runs, readSlackMetadata must accept the merged value.
1582
+ it("after reconciliation, the row has a Slack view the transcript renderer accepts", async () => {
1583
+ // The Slack renderers read the neutral envelope through its Slack
1584
+ // view; before the stamp there is none, after it there is.
1274
1585
  pushPartialAssistantRow("conv-readback", "msg-readback", "C222");
1586
+ const { readSlackMetadataFromMessageMetadata } =
1587
+ await import("../messaging/providers/slack/message-metadata.js");
1588
+ const before = conversationMessages.find((m) => m.id === "msg-readback");
1589
+ expect(readSlackMetadataFromMessageMetadata(before?.metadata)).toBeNull();
1275
1590
  nextDeliveryTs = "1700000700.000333";
1276
1591
 
1277
1592
  await deliverReplyViaCallback(
@@ -1281,18 +1596,163 @@ describe("channel-reply-delivery", () => {
1281
1596
  "assistant-readback",
1282
1597
  );
1283
1598
 
1599
+ const after = conversationMessages.find((m) => m.id === "msg-readback");
1600
+ const view = readSlackMetadataFromMessageMetadata(after?.metadata);
1601
+ expect(view).not.toBeNull();
1602
+ expect(view?.channelTs).toBe("1700000700.000333");
1603
+ expect(view?.channelId).toBe("C222");
1604
+ expect(view?.source).toBe("slack");
1605
+ expect(view?.eventKind).toBe("message");
1606
+ expect(view?.timestampTimezone).toBe("America/New_York");
1607
+ });
1608
+
1609
+ it("retries a reconciliation write that hits transient SQLite contention", async () => {
1610
+ // The stamp is the only durable record of the post's id: no later sweep
1611
+ // heals a miss once the event is marked delivered, so a SQLITE_BUSY on
1612
+ // the first attempt must not lose it.
1613
+ pushPartialAssistantRow("conv-busy", "msg-busy", "C333");
1614
+ nextDeliveryTs = "1700000800.000444";
1615
+ metadataWriteFailuresRemaining = 1;
1616
+
1617
+ await deliverReplyViaCallback(
1618
+ "conv-busy",
1619
+ "C333",
1620
+ "http://gateway/deliver/slack",
1621
+ "assistant-busy",
1622
+ );
1623
+
1284
1624
  expect(updateMessageMetadataCalls.length).toBe(1);
1285
- const merged = updateMessageMetadataCalls[0].updates.slackMeta as string;
1286
- // Imported here so the production read path (the same one the renderer
1287
- // uses) is what actually validates the merged envelope.
1288
- const { readSlackMetadata } =
1289
- await import("../messaging/providers/slack/message-metadata.js");
1290
- const parsed = readSlackMetadata(merged);
1291
- expect(parsed).not.toBeNull();
1292
- expect(parsed?.channelTs).toBe("1700000700.000333");
1293
- expect(parsed?.channelId).toBe("C222");
1294
- expect(parsed?.source).toBe("slack");
1295
- expect(parsed?.eventKind).toBe("message");
1625
+ expect(envelopeOf("msg-busy").messageId).toBe("1700000800.000444");
1626
+ });
1627
+
1628
+ it("stamps from a later segment when the first segment's write is lost", async () => {
1629
+ // The row records the first id that reaches a DURABLE write, not the
1630
+ // first id observed: a first-invocation latch would forfeit the id
1631
+ // forever when that write fails, and the row would never gain an id
1632
+ // of its own.
1633
+ pushPartialAssistantRow("conv-lost", "msg-lost", "C444");
1634
+ twoSegments();
1635
+ deliveryTsQueue.push("1700000900.000555", "1700000900.000556");
1636
+ // Exhaust the initial attempt plus every `withSqliteRetry` retry, so the
1637
+ // first segment's stamp is genuinely swallowed.
1638
+ metadataWriteFailuresRemaining = 4;
1639
+
1640
+ await deliverReplyViaCallback(
1641
+ "conv-lost",
1642
+ "C444",
1643
+ "http://gateway/deliver/slack",
1644
+ "assistant-lost",
1645
+ );
1646
+
1647
+ expect(deliveryCalls.length).toBe(2);
1648
+ expect(updateMessageMetadataCalls.length).toBe(1);
1649
+ expect(envelopeOf("msg-lost").messageId).toBe("1700000900.000556");
1650
+ // Both posts still reached the index, which does not depend on the
1651
+ // envelope write.
1652
+ expect(
1653
+ recordedOutboundPosts.map((post) => post.providerMessageId),
1654
+ ).toEqual(["1700000900.000555", "1700000900.000556"]);
1655
+ });
1656
+
1657
+ it("commits the reconciliation write before posting the next segment", async () => {
1658
+ // `onMessageTs` is awaited, so the row is durable at each segment
1659
+ // boundary. Otherwise a crash between two posts of a split reply loses
1660
+ // the stamp for a message the reader can already see.
1661
+ pushPartialAssistantRow("conv-order", "msg-order", "C555");
1662
+ twoSegments();
1663
+ deliveryTsQueue.push("1700001000.000666", "1700001000.000667");
1664
+ const seenAtSecondPost: unknown[] = [];
1665
+ onRecordOutboundPost = (post) => {
1666
+ if (post.providerMessageId === "1700001000.000667") {
1667
+ seenAtSecondPost.push(envelopeOf("msg-order").messageId);
1668
+ }
1669
+ };
1670
+
1671
+ await deliverReplyViaCallback(
1672
+ "conv-order",
1673
+ "C555",
1674
+ "http://gateway/deliver/slack",
1675
+ "assistant-order",
1676
+ );
1677
+
1678
+ expect(seenAtSecondPost).toEqual(["1700001000.000666"]);
1679
+ });
1680
+
1681
+ it("keeps a deletion that lands between the index write and the envelope stamp", async () => {
1682
+ // Once the index names the post, a delete resolves to the row and
1683
+ // stamps the envelope that still lacks its id. The stamp that follows
1684
+ // must read that state, not the one it saw before the index write,
1685
+ // and keep the row fully deleted: its only post is gone.
1686
+ pushPartialAssistantRow("conv-window", "msg-window", "C666");
1687
+ nextDeliveryTs = "1700001100.000777";
1688
+ onRecordOutboundPost = () => {
1689
+ const row = conversationMessages.find((m) => m.id === "msg-window")!;
1690
+ const outer = JSON.parse(row.metadata!) as Record<string, string>;
1691
+ row.metadata = JSON.stringify({
1692
+ ...outer,
1693
+ providerMeta: JSON.stringify({
1694
+ ...(JSON.parse(outer.providerMeta) as Record<string, unknown>),
1695
+ deletedMessageIds: ["1700001100.000777"],
1696
+ deletedAt: 1700001101000,
1697
+ }),
1698
+ });
1699
+ };
1700
+
1701
+ await deliverReplyViaCallback(
1702
+ "conv-window",
1703
+ "C666",
1704
+ "http://gateway/deliver/slack",
1705
+ "assistant-window",
1706
+ );
1707
+
1708
+ const envelope = envelopeOf("msg-window");
1709
+ expect(envelope.messageId).toBe("1700001100.000777");
1710
+ expect(envelope.deletedMessageIds).toEqual(["1700001100.000777"]);
1711
+ expect(envelope.deletedAt).toBe(1700001101000);
1712
+ });
1713
+
1714
+ it("clears the row-level marker when a later post arrives after every known post was deleted", async () => {
1715
+ // The first post was deleted before the second segment reconciled, so
1716
+ // the row read as fully deleted. The second post is on the channel,
1717
+ // so the row is not; the per-post record of the first deletion stays.
1718
+ conversationMessages.push({
1719
+ id: "msg-revive",
1720
+ role: "assistant",
1721
+ content: '[{"type":"text","text":"hello"}]',
1722
+ metadata: JSON.stringify({
1723
+ assistantMessageChannel: "slack",
1724
+ providerMeta: JSON.stringify({
1725
+ source: "slack",
1726
+ conversationExternalId: "C777",
1727
+ eventKind: "message",
1728
+ messageId: "1700001200.000100",
1729
+ deletedMessageIds: ["1700001200.000100"],
1730
+ deletedAt: 1700001201000,
1731
+ }),
1732
+ }),
1733
+ });
1734
+ renderedHistoryContent = {
1735
+ text: "second",
1736
+ textSegments: ["second"],
1737
+ toolCalls: [],
1738
+ toolCallsBeforeText: false,
1739
+ contentOrder: ["text:0"],
1740
+ surfaces: [],
1741
+ thinkingSegments: [],
1742
+ };
1743
+ nextDeliveryTs = "1700001200.000200";
1744
+
1745
+ await deliverReplyViaCallback(
1746
+ "conv-revive",
1747
+ "C777",
1748
+ "http://gateway/deliver/slack",
1749
+ "assistant-revive",
1750
+ );
1751
+
1752
+ const envelope = envelopeOf("msg-revive");
1753
+ expect(envelope.additionalMessageIds).toEqual(["1700001200.000200"]);
1754
+ expect(envelope.deletedMessageIds).toEqual(["1700001200.000100"]);
1755
+ expect(envelope.deletedAt).toBeUndefined();
1296
1756
  });
1297
1757
  });
1298
1758
  });