@vellumai/assistant 0.11.8 → 0.11.9-staging.1

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 (318) 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__/guardian-card-withdrawal.test.ts +0 -22
  64. package/src/__tests__/guardian-gateway-sim.ts +0 -23
  65. package/src/__tests__/guardian-question-mode.test.ts +108 -0
  66. package/src/__tests__/guardian-reply-router-answer-mode.test.ts +0 -2
  67. package/src/__tests__/guardian-routing-invariants.test.ts +26 -12
  68. package/src/__tests__/host-proxy-interface.test.ts +10 -0
  69. package/src/__tests__/inbound-slack-persistence.test.ts +16 -0
  70. package/src/__tests__/injector-v3-suppression.test.ts +43 -9
  71. package/src/__tests__/list-messages-page-latest.test.ts +127 -0
  72. package/src/__tests__/list-messages-system-card.test.ts +103 -0
  73. package/src/__tests__/media-resolve-image-validation.test.ts +77 -0
  74. package/src/__tests__/notification-decision-fallback.test.ts +199 -77
  75. package/src/__tests__/notification-decision-strategy.test.ts +198 -213
  76. package/src/__tests__/notification-discord-adapter.test.ts +25 -0
  77. package/src/__tests__/notification-slack-adapter.test.ts +133 -0
  78. package/src/__tests__/notification-telegram-adapter.test.ts +36 -0
  79. package/src/__tests__/openai-provider.test.ts +5 -5
  80. package/src/__tests__/outbound-slack-persistence.test.ts +85 -72
  81. package/src/__tests__/persist-user-message-set-processing-failure.test.ts +83 -65
  82. package/src/__tests__/platform-client-verify-credential.test.ts +100 -0
  83. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -1
  84. package/src/__tests__/process-message-display-content.test.ts +20 -9
  85. package/src/__tests__/processing-acquire-fenced-guard.test.ts +56 -0
  86. package/src/__tests__/provider-meta-persistence.test.ts +67 -0
  87. package/src/__tests__/reaction-persistence.test.ts +22 -198
  88. package/src/__tests__/run-conversation-turn-persistence.test.ts +5 -2
  89. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +16 -0
  90. package/src/__tests__/skill-load-tool.test.ts +27 -0
  91. package/src/__tests__/skills.test.ts +34 -1
  92. package/src/__tests__/strip-memory-injections.test.ts +3 -4
  93. package/src/__tests__/terminal-tools.test.ts +9 -0
  94. package/src/__tests__/thread-backfill.test.ts +5 -3
  95. package/src/__tests__/unified-turn-context-location.test.ts +76 -0
  96. package/src/__tests__/voice-session-bridge.test.ts +18 -0
  97. package/src/__tests__/watch-retro-report-payload.test.ts +185 -0
  98. package/src/__tests__/watch-retro-tool-availability.test.ts +82 -0
  99. package/src/__tests__/workspace-migration-151-repair-renamed-fireworks-deepseek-pro-model-id.test.ts +235 -0
  100. package/src/__tests__/workspace-migration-152-repair-retired-fireworks-minimax-m2p7-model-id.test.ts +233 -0
  101. package/src/agent/attachments.ts +13 -2
  102. package/src/agent/loop.ts +3 -19
  103. package/src/api/README.md +9 -5
  104. package/src/api/index.ts +9 -8
  105. package/src/api/package.json +1 -0
  106. package/src/api/responses/conversation-message.ts +8 -0
  107. package/src/api/responses/home.ts +7 -18
  108. package/src/api/surfaces.ts +114 -7
  109. package/src/approvals/AGENTS.md +1 -1
  110. package/src/channels/__tests__/gateway-guardian-requests.test.ts +1 -23
  111. package/src/channels/__tests__/types.test.ts +22 -1
  112. package/src/channels/gateway-guardian-requests.ts +0 -22
  113. package/src/channels/types.ts +8 -6
  114. package/src/cli/__tests__/catalog-search-help.test.ts +12 -0
  115. package/src/cli/commands/channels/__tests__/channels.test.ts +26 -0
  116. package/src/cli/commands/channels/index.help.ts +15 -1
  117. package/src/cli/commands/channels/index.ts +6 -2
  118. package/src/cli/commands/db/__tests__/status.test.ts +22 -0
  119. package/src/cli/commands/db/index.help.ts +1 -1
  120. package/src/cli/commands/db/status.ts +172 -1
  121. package/src/cli/commands/platform/__tests__/connect.test.ts +29 -0
  122. package/src/cli/commands/platform/connect.ts +14 -5
  123. package/src/cli/commands/plugins.help.ts +6 -5
  124. package/src/cli/lib/__tests__/install-from-github.test.ts +0 -8
  125. package/src/cli/lib/__tests__/install-from-platform.test.ts +72 -0
  126. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +33 -0
  127. package/src/cli/lib/bundled-marketplace.json +14 -0
  128. package/src/cli/lib/install-from-github.ts +9 -5
  129. package/src/cli/lib/install-from-platform.ts +12 -1
  130. package/src/config/__tests__/assistant-initiated-threads-gate.test.ts +61 -0
  131. package/src/config/assistant-initiated-threads-gate.ts +50 -0
  132. package/src/config/bundled-skills/acp/SKILL.md +10 -3
  133. package/src/config/bundled-skills/schedule/SKILL.md +25 -11
  134. package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +3 -1
  135. package/src/config/call-site-defaults.ts +4 -1
  136. package/src/config/feature-flag-registry.json +21 -4
  137. package/src/config/schemas/memory-v3.ts +4 -3
  138. package/src/context/strip-injections.ts +17 -56
  139. package/src/conversations/__tests__/message-consolidation.test.ts +39 -0
  140. package/src/conversations/message-consolidation.ts +4 -3
  141. package/src/credential-health/credential-health-service.ts +130 -0
  142. package/src/daemon/__tests__/conversation-tool-setup.test.ts +31 -0
  143. package/src/daemon/conversation-agent-loop-handlers.ts +55 -59
  144. package/src/daemon/conversation-error.ts +2 -0
  145. package/src/daemon/conversation-messaging.ts +195 -47
  146. package/src/daemon/conversation-process.ts +20 -5
  147. package/src/daemon/conversation-runtime-assembly.ts +48 -39
  148. package/src/daemon/conversation-store.ts +159 -11
  149. package/src/daemon/conversation-tool-setup.ts +9 -2
  150. package/src/daemon/conversation.ts +290 -19
  151. package/src/daemon/dictation-text-processing.ts +2 -7
  152. package/src/daemon/handlers/config-channels.ts +33 -3
  153. package/src/daemon/handlers/shared.ts +9 -0
  154. package/src/daemon/port-oversized-content.test.ts +117 -0
  155. package/src/daemon/port-oversized-content.ts +110 -0
  156. package/src/daemon/process-message.ts +87 -16
  157. package/src/daemon/reaction-record.test.ts +23 -11
  158. package/src/daemon/reaction-record.ts +6 -9
  159. package/src/documents/document-store.ts +1 -5
  160. package/src/home/conversation-starter-validation.ts +1 -5
  161. package/src/live-voice/__tests__/live-voice-photo.test.ts +910 -50
  162. package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +31 -25
  163. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +70 -1
  164. package/src/live-voice/live-voice-photo.ts +544 -61
  165. package/src/live-voice/live-voice-session.ts +6 -2
  166. package/src/live-voice/protocol.ts +20 -1
  167. package/src/messaging/provider-message-metadata.ts +40 -1
  168. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +10 -2
  169. package/src/messaging/providers/channel-transport.ts +28 -0
  170. package/src/messaging/providers/discord/send.ts +3 -2
  171. package/src/messaging/providers/slack/api.ts +11 -1
  172. package/src/messaging/providers/slack/message-metadata.test.ts +133 -0
  173. package/src/messaging/providers/slack/message-metadata.ts +141 -1
  174. package/src/messaging/providers/slack/send.test.ts +58 -0
  175. package/src/messaging/providers/slack/send.ts +4 -4
  176. package/src/messaging/providers/slack/transport.ts +28 -2
  177. package/src/messaging/providers/telegram-bot/send.test.ts +159 -0
  178. package/src/messaging/providers/telegram-bot/send.ts +93 -0
  179. package/src/messaging/providers/telegram-bot/transport.ts +71 -0
  180. package/src/messaging/reaction-envelopes.test.ts +73 -0
  181. package/src/messaging/reaction-envelopes.ts +33 -5
  182. package/src/messaging/read-provider-metadata.ts +4 -3
  183. package/src/monitoring/recovery/__tests__/stranded-delivery-events.test.ts +208 -0
  184. package/src/monitoring/recovery/db.ts +35 -0
  185. package/src/monitoring/recovery/orphaned-channel-events.ts +3 -22
  186. package/src/monitoring/recovery/run-recovery.ts +6 -2
  187. package/src/monitoring/recovery/stale-processing.ts +3 -20
  188. package/src/monitoring/recovery/stranded-delivery-events.ts +68 -0
  189. package/src/notifications/AGENTS.md +2 -2
  190. package/src/notifications/README.md +2 -2
  191. package/src/notifications/__tests__/assistant-reply-producer.test.ts +11 -9
  192. package/src/notifications/__tests__/broadcaster.test.ts +35 -4
  193. package/src/notifications/__tests__/notification-utils.test.ts +31 -0
  194. package/src/notifications/access-request-copy.ts +126 -114
  195. package/src/notifications/adapters/discord.ts +9 -5
  196. package/src/notifications/adapters/shared.ts +17 -4
  197. package/src/notifications/adapters/slack.ts +14 -4
  198. package/src/notifications/adapters/telegram.ts +8 -4
  199. package/src/notifications/approval-card-data.ts +5 -2
  200. package/src/notifications/assistant-reply-producer.ts +7 -7
  201. package/src/notifications/broadcaster.ts +57 -25
  202. package/src/notifications/conversation-pairing.ts +68 -2
  203. package/src/notifications/copy-composer.ts +13 -32
  204. package/src/notifications/decision-engine.ts +56 -237
  205. package/src/notifications/guardian-delivery-recorder.ts +5 -9
  206. package/src/notifications/guardian-feed-projection.ts +0 -15
  207. package/src/notifications/guardian-question-mode.ts +147 -6
  208. package/src/notifications/notification-utils.ts +117 -1
  209. package/src/permissions/confirmation-guardian-request.ts +2 -2
  210. package/src/permissions/prompter.ts +1 -1
  211. package/src/persistence/conversation-crud.ts +93 -14
  212. package/src/persistence/conversation-queries.ts +144 -17
  213. package/src/persistence/conversation-types.ts +39 -5
  214. package/src/persistence/delivery-crud.ts +224 -28
  215. package/src/persistence/delivery-status.ts +21 -2
  216. package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +26 -0
  217. package/src/persistence/migrations/375-create-channel-outbound-posts.ts +52 -0
  218. package/src/persistence/migrations/__tests__/375-create-channel-outbound-posts.test.ts +84 -0
  219. package/src/persistence/schema/conversations.ts +71 -2
  220. package/src/persistence/schema-contract.test.ts +78 -0
  221. package/src/persistence/schema-contract.ts +96 -0
  222. package/src/persistence/steps.ts +4 -0
  223. package/src/platform/client.ts +55 -0
  224. package/src/plugins/__tests__/mcp-servers.test.ts +46 -0
  225. package/src/plugins/defaults/injector-order.ts +2 -1
  226. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +2 -2
  227. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +13 -15
  228. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +10 -0
  229. package/src/plugins/defaults/memory/injectors.ts +1 -1
  230. package/src/plugins/defaults/memory/memory-marker.ts +17 -7
  231. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +1 -5
  232. package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +54 -41
  233. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +3 -3
  234. package/src/plugins/defaults/memory/v3/injector.ts +14 -15
  235. package/src/plugins/defaults/memory/v3/types.ts +15 -4
  236. package/src/plugins/defaults/turn-context/injectors.ts +3 -0
  237. package/src/plugins/defaults/turn-context/unified-turn-context.ts +24 -0
  238. package/src/plugins/mcp-servers.ts +14 -2
  239. package/src/providers/__tests__/dispatch-connection-routing.test.ts +50 -0
  240. package/src/providers/__tests__/registry-native-web-search.test.ts +49 -2
  241. package/src/providers/anthropic/client.ts +21 -24
  242. package/src/providers/call-site-routing.ts +5 -4
  243. package/src/providers/connection-resolution.ts +29 -1
  244. package/src/providers/content-block-size.test.ts +82 -0
  245. package/src/providers/content-block-size.ts +99 -0
  246. package/src/providers/file-block-text.test.ts +64 -0
  247. package/src/providers/file-block-text.ts +28 -0
  248. package/src/providers/gemini/client.ts +12 -7
  249. package/src/providers/inference/auth.ts +6 -6
  250. package/src/providers/media-resolve.ts +14 -0
  251. package/src/providers/model-catalog.ts +31 -34
  252. package/src/providers/openai/chat-completions-provider.ts +9 -25
  253. package/src/providers/openai/responses-provider.ts +11 -18
  254. package/src/providers/registry.ts +10 -7
  255. package/src/providers/routing-identity.ts +2 -1
  256. package/src/providers/types.ts +1 -4
  257. package/src/providers/vellum-model-routing.ts +3 -2
  258. package/src/runtime/AGENTS.md +40 -15
  259. package/src/runtime/approval-message-composer.ts +1 -6
  260. package/src/runtime/assistant-event-hub.ts +2 -39
  261. package/src/runtime/channel-approval-types.ts +1 -5
  262. package/src/runtime/channel-reply-delivery.ts +182 -115
  263. package/src/runtime/{slack-reply-session.test.ts → channel-reply-session.test.ts} +294 -156
  264. package/src/runtime/{slack-reply-session.ts → channel-reply-session.ts} +107 -86
  265. package/src/runtime/channel-retry-sweep.ts +102 -1
  266. package/src/runtime/finalize-event-delivery.ts +4 -4
  267. package/src/runtime/guardian-action-message-composer.ts +1 -4
  268. package/src/runtime/guardian-reply-router.ts +4 -75
  269. package/src/runtime/http-router.ts +1 -5
  270. package/src/runtime/question-request-guardian-bridge.ts +2 -3
  271. package/src/runtime/routes/__tests__/conversation-list-assistant-section.test.ts +359 -0
  272. package/src/runtime/routes/__tests__/dictation-command-mode.test.ts +120 -0
  273. package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +487 -0
  274. package/src/runtime/routes/acp-routes.ts +1 -1
  275. package/src/runtime/routes/canned-reply-release.ts +19 -9
  276. package/src/runtime/routes/channel-route-shared.ts +0 -39
  277. package/src/runtime/routes/channel-verification-routes.ts +4 -1
  278. package/src/runtime/routes/conversation-list-routes.ts +44 -3
  279. package/src/runtime/routes/conversation-management-routes.ts +2 -1
  280. package/src/runtime/routes/conversation-routes.ts +138 -59
  281. package/src/runtime/routes/diagnostics-routes.ts +111 -60
  282. package/src/runtime/routes/guardian-action-routes.ts +10 -14
  283. package/src/runtime/routes/guardian-approval-interception.ts +0 -5
  284. package/src/runtime/routes/inbound-message-handler.ts +170 -48
  285. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +4 -3
  286. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +169 -8
  287. package/src/runtime/routes/inbound-stages/background-dispatch.ts +82 -14
  288. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +23 -48
  289. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +454 -94
  290. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +309 -102
  291. package/src/runtime/routes/index.ts +2 -0
  292. package/src/runtime/routes/platform-routes.ts +99 -10
  293. package/src/runtime/routes/sight-frame-routes.ts +136 -0
  294. package/src/runtime/sync/resource-sync-events.ts +14 -37
  295. package/src/runtime/sync/sync-publisher.test.ts +5 -4
  296. package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
  297. package/src/tools/client-os.ts +11 -1
  298. package/src/tools/host-filesystem/edit.ts +2 -2
  299. package/src/tools/host-filesystem/read.ts +2 -2
  300. package/src/tools/host-filesystem/transfer.ts +2 -2
  301. package/src/tools/host-filesystem/write.ts +2 -2
  302. package/src/tools/host-terminal/host-shell.ts +2 -2
  303. package/src/tools/skills/load.ts +1 -1
  304. package/src/tools/terminal/safe-env.ts +4 -0
  305. package/src/tools/tool-input-schemas.ts +2 -0
  306. package/src/tools/tool-manifest.ts +2 -0
  307. package/src/tools/ui-surface/definitions.ts +4 -3
  308. package/src/tools/watch/watch-retro-report.ts +205 -0
  309. package/src/watch/__tests__/watch-retro.test.ts +268 -40
  310. package/src/watch/watch-retro.ts +190 -77
  311. package/src/workspace/migrations/151-repair-renamed-fireworks-deepseek-pro-model-id.ts +195 -0
  312. package/src/workspace/migrations/152-repair-retired-fireworks-minimax-m2p7-model-id.ts +198 -0
  313. package/src/workspace/migrations/__tests__/150-stt-flux-provider-to-model-family.test.ts +0 -10
  314. package/src/workspace/migrations/registry.ts +4 -0
  315. package/docker-kata-pip-chroot.sh +0 -22
  316. package/src/__tests__/slack-reaction-approvals.test.ts +0 -97
  317. package/src/__tests__/slack-reaction-guardian-approval.test.ts +0 -307
  318. package/src/api/events/conversation-list-invalidated.ts +0 -38
@@ -46,6 +46,7 @@ import { classifyDiskPressureTurnPolicy } from "../../daemon/disk-pressure-polic
46
46
  import { processMessage } from "../../daemon/process-message.js";
47
47
  import type { TrustContext } from "../../daemon/trust-context-types.js";
48
48
  import { HeartbeatService } from "../../heartbeat/heartbeat-service.js";
49
+ import type { ProviderMessageMetadata } from "../../messaging/provider-message-metadata.js";
49
50
  import type { Message as ProviderMessage } from "../../messaging/provider-types.js";
50
51
  import { editChannelMessage } from "../../messaging/providers/index.js";
51
52
  import {
@@ -68,7 +69,10 @@ import {
68
69
  type SlackMessageMetadata,
69
70
  writeSlackMetadata,
70
71
  } from "../../messaging/providers/slack/message-metadata.js";
71
- import { mergeProviderMessageMetadata } from "../../messaging/read-provider-metadata.js";
72
+ import {
73
+ mergeProviderMessageMetadata,
74
+ readProviderMetadata,
75
+ } from "../../messaging/read-provider-metadata.js";
72
76
  import { MESSAGE_PREVIEW_MAX_LENGTH } from "../../notifications/notification-utils.js";
73
77
  import {
74
78
  attachInlineAttachmentToMessage,
@@ -91,8 +95,11 @@ import {
91
95
  import { applyDeterministicTitleIfReplaceable } from "../../persistence/conversation-title-service.js";
92
96
  import {
93
97
  clearPayload,
98
+ DELETE_PROVIDER_MESSAGE_ID_MAX_SCAN,
99
+ findMessageByProviderMessageId,
94
100
  findMessageBySourceId,
95
101
  hasInboundEventForSource,
102
+ hasUnreconciledOutboundRow,
96
103
  recordInbound,
97
104
  } from "../../persistence/delivery-crud.js";
98
105
  import { markProcessed } from "../../persistence/delivery-status.js";
@@ -110,6 +117,7 @@ import {
110
117
  } from "../access-request-helper.js";
111
118
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "../assistant-scope.js";
112
119
  import { deliverChannelReply } from "../gateway-client.js";
120
+ import { publishConversationMessagesChanged } from "../sync/resource-sync-events.js";
113
121
  import { trustContextFromVerdict } from "../trust-verdict-consumer.js";
114
122
  import { BadRequestError } from "./errors.js";
115
123
  import { handleApprovalInterception } from "./guardian-approval-interception.js";
@@ -149,6 +157,13 @@ const DISK_PRESSURE_REMOTE_BLOCK_REPLY =
149
157
  // EDIT_LOOKUP_RETRIES / EDIT_LOOKUP_DELAY_MS constants.
150
158
  let deleteLookupRetries = 5;
151
159
  let deleteLookupDelayMs = 2000;
160
+ /**
161
+ * Attempts during which a delete with no inbound event keeps retrying the
162
+ * envelope lane: an assistant post's provider id lands with the post-send
163
+ * reconciliation, one delivery round-trip behind the post itself, so a
164
+ * couple of backoff steps bridge an automated deletion that outruns it.
165
+ */
166
+ const deleteOutboundReconcileRetries = 2;
152
167
 
153
168
  interface SlackActorTimezoneMetadata {
154
169
  timezone?: string;
@@ -434,13 +449,13 @@ export async function handleChannelInbound({
434
449
  // pipeline (ACL, admission floor, disk-pressure, conversation binding) so a
435
450
  // 👍 never triggers a verification handshake or an access-request
436
451
  // notification, and a stranger's reaction creates no conversation/binding.
437
- // The interceptor drops strangers, records known contacts' reactions as
438
- // transcript signals in the conversation of the reacted message, and routes
439
- // a guardian's reaction on an approval card through the guardian decision
440
- // pipeline. Reactions never mint a conversation and never drive an agent
441
- // turn. A family member whose payload does not resolve (no emoji or no
442
- // target message id) is dropped as noise here: the kind names the family,
443
- // so it must never fall through and be read as a message.
452
+ // The interceptor drops strangers and records an admitted actor's reaction
453
+ // as a transcript signal in the conversation of the reacted message.
454
+ // Reactions never mint a conversation; the one that wakes a turn (a
455
+ // reaction on the assistant's own post) is the interceptor's call. A family member whose payload does not
456
+ // resolve (no emoji or no target message id) is dropped as noise here:
457
+ // the kind names the family, so it must never fall through and be read
458
+ // as a message.
444
459
  if (isReactionEvent(body)) {
445
460
  const reaction = resolveInboundReactionPayload(body);
446
461
  if (!reaction) {
@@ -462,7 +477,6 @@ export async function handleChannelInbound({
462
477
  actorUsername: body.actorUsername,
463
478
  replyCallbackUrl: body.replyCallbackUrl,
464
479
  sourceMetadata: body.sourceMetadata,
465
- approvalConversationGenerator,
466
480
  });
467
481
  }
468
482
 
@@ -527,10 +541,11 @@ export async function handleChannelInbound({
527
541
  // A delete names the original via `sourceMetadata.messageId` and
528
542
  // short-circuits the rest of the pipeline: no agent loop, no approval
529
543
  // routing. The stored row keeps its content for audit; rendering elides on
530
- // the deletedAt marker. An attributed delete (the wire names the deleted
531
- // message's author, as Slack's does) passed the ingress ACL above; an
532
- // unattributed one bypassed it under the ACL's stated contract and applies
533
- // only to a row this daemon ingested.
544
+ // the deletedAt marker. An attributed delete (the wire names a human
545
+ // author for the deleted message, as Slack's does for a member's post)
546
+ // passed the ingress ACL above; an unattributed one bypassed it under the
547
+ // ACL's stated contract and applies only to a row this daemon already
548
+ // holds for the chat.
534
549
  if (eventKind === "delete") {
535
550
  const deletedMessageTs =
536
551
  typeof sourceMetadata?.messageId === "string"
@@ -556,20 +571,38 @@ export async function handleChannelInbound({
556
571
  // that window is silently dropped and the deletion signal is lost.
557
572
  let original: { messageId: string; conversationId: string } | null = null;
558
573
  for (let attempt = 0; attempt <= deleteLookupRetries; attempt++) {
559
- original = findMessageBySourceId(
560
- sourceChannel,
561
- conversationExternalId,
562
- deletedMessageTs,
563
- );
574
+ // Two resolution lanes, each with its own race. An inbound row's
575
+ // event is written before its message link lands (retried until
576
+ // `hasInboundEventForSource` says the source was never ingested). An
577
+ // assistant post opens no inbound event; its provider id is written
578
+ // by the post-send reconciliation, which an automated deletion can
579
+ // outrun, so the envelope lane retries within the same bounded
580
+ // window rather than giving up on the first miss.
581
+ original =
582
+ findMessageBySourceId(
583
+ sourceChannel,
584
+ conversationExternalId,
585
+ deletedMessageTs,
586
+ ) ??
587
+ findMessageByProviderMessageId(
588
+ sourceChannel,
589
+ conversationExternalId,
590
+ deletedMessageTs,
591
+ );
564
592
  if (original) {
565
593
  break;
566
594
  }
567
- // The retry window exists for one race: the original's inbound-event
568
- // row is written before its message link lands. A source with no row
569
- // at all was never ingested, so nothing can appear by waiting, and
570
- // waiting would hold this conversation's serialized lane through the
571
- // full miss window for every unrelated delete a busy room produces.
595
+ // A source with no inbound row was either never ingested (nothing can
596
+ // appear by waiting) or is an assistant post racing its post-send id
597
+ // reconciliation. Only the second earns a wait, and it leaves
598
+ // evidence: a recent outbound row whose envelope names no id yet.
599
+ // Without that evidence the miss is final, so an unrelated delete in
600
+ // a busy room never holds the serialized lane through the window.
601
+ const raceStillPossible =
602
+ attempt < deleteOutboundReconcileRetries &&
603
+ hasUnreconciledOutboundRow(sourceChannel, conversationExternalId);
572
604
  if (
605
+ !raceStillPossible &&
573
606
  !hasInboundEventForSource(
574
607
  sourceChannel,
575
608
  conversationExternalId,
@@ -586,7 +619,7 @@ export async function handleChannelInbound({
586
619
  attempt: attempt + 1,
587
620
  maxAttempts: deleteLookupRetries,
588
621
  },
589
- "Original message not linked yet, retrying delete lookup",
622
+ "Original message not resolved yet, retrying delete lookup",
590
623
  );
591
624
  await new Promise((resolve) =>
592
625
  setTimeout(resolve, deleteLookupDelayMs),
@@ -594,6 +627,17 @@ export async function handleChannelInbound({
594
627
  }
595
628
  }
596
629
 
630
+ // Recency-capped misses above could still be an OLD assistant post:
631
+ // deletion can target one arbitrarily far back, so the last resort is a
632
+ // single deep scan. Once, after the retries: age and race are exclusive
633
+ // (a post old enough to be past the recent window reconciled long ago).
634
+ original ??= findMessageByProviderMessageId(
635
+ sourceChannel,
636
+ conversationExternalId,
637
+ deletedMessageTs,
638
+ { maxScan: DELETE_PROVIDER_MESSAGE_ID_MAX_SCAN },
639
+ );
640
+
597
641
  if (!original) {
598
642
  log.debug(
599
643
  { conversationExternalId, deletedMessageTs },
@@ -618,22 +662,50 @@ export async function handleChannelInbound({
618
662
  : null;
619
663
 
620
664
  if (!existingSlackMeta) {
621
- const providerMeta = mergeProviderMessageMetadata(
622
- row?.metadata ?? null,
623
- {
624
- source: sourceChannel,
625
- conversationExternalId,
626
- messageId: deletedMessageTs,
627
- ...(sourceMetadata?.threadId
628
- ? { threadId: sourceMetadata.threadId }
629
- : {}),
630
- },
631
- { deletedAt: Date.now() },
632
- );
665
+ // Deletion is tracked per provider post: a split reply's row names
666
+ // several posts, and deleting one must not read as the whole reply
667
+ // vanishing. The row-level `deletedAt` lands only when every post the
668
+ // row names is gone; a row whose envelope names no id (a legacy row
669
+ // synthesized from lookup facts) is single-post by construction and
670
+ // keeps the direct stamp.
671
+ const base = readProviderMetadata(row?.metadata ?? null, {
672
+ allowFlatLegacy: true,
673
+ });
674
+ let providerMeta: string;
675
+ if (base?.messageId) {
676
+ const rowPostIds = [
677
+ base.messageId,
678
+ ...(base.additionalMessageIds ?? []),
679
+ ];
680
+ const deletedIds = Array.from(
681
+ new Set([...(base.deletedMessageIds ?? []), deletedMessageTs]),
682
+ );
683
+ const fullyDeleted = rowPostIds.every((id) => deletedIds.includes(id));
684
+ providerMeta = JSON.stringify({
685
+ ...base,
686
+ deletedMessageIds: deletedIds,
687
+ ...(fullyDeleted ? { deletedAt: Date.now() } : {}),
688
+ });
689
+ } else {
690
+ providerMeta = mergeProviderMessageMetadata(
691
+ row?.metadata ?? null,
692
+ {
693
+ source: sourceChannel,
694
+ conversationExternalId,
695
+ messageId: deletedMessageTs,
696
+ ...(sourceMetadata?.threadId
697
+ ? { threadId: sourceMetadata.threadId }
698
+ : {}),
699
+ },
700
+ { deletedAt: Date.now() },
701
+ );
702
+ }
633
703
  updateMessageMetadata(original.messageId, { providerMeta });
634
- // The stamp lands in the store only; stale-marking makes a resident
635
- // conversation's next turn reload and see the row as deleted.
704
+ // The stamp lands in the store only: stale-marking makes a resident
705
+ // conversation's next turn reload and see the row as deleted, and the
706
+ // invalidation makes a client showing the conversation refetch it.
636
707
  findConversation(original.conversationId)?.markHistoryStale();
708
+ publishConversationMessagesChanged(original.conversationId);
637
709
  log.info(
638
710
  {
639
711
  conversationExternalId,
@@ -655,6 +727,7 @@ export async function handleChannelInbound({
655
727
  // is intentionally not updated.
656
728
  updateMessageMetadata(original.messageId, { slackMeta: updatedSlackMeta });
657
729
  findConversation(original.conversationId)?.markHistoryStale();
730
+ publishConversationMessagesChanged(original.conversationId);
658
731
 
659
732
  log.info(
660
733
  {
@@ -1417,6 +1490,30 @@ export async function handleChannelInbound({
1417
1490
  }
1418
1491
  : undefined;
1419
1492
 
1493
+ // Neutral per-row envelope for every non-Slack channel, mirroring the
1494
+ // Slack capture above field for field: it is persisted as the row's
1495
+ // `providerMeta` so the row can say which external message it is, in
1496
+ // which thread, from whom. Slack keeps its own `slackMeta` envelope
1497
+ // (mapped to the neutral shape on read), so nothing is stored twice.
1498
+ const inboundActorDisplayName =
1499
+ body.actorDisplayName ?? body.actorUsername;
1500
+ const channelInbound: ProviderMessageMetadata | undefined =
1501
+ sourceChannel !== "slack"
1502
+ ? {
1503
+ source: sourceChannel,
1504
+ conversationExternalId,
1505
+ messageId: sourceMessageId ?? externalMessageId,
1506
+ eventKind: "message",
1507
+ ...(channelThreadId ? { threadId: channelThreadId } : {}),
1508
+ ...(inboundActorDisplayName
1509
+ ? { displayName: inboundActorDisplayName }
1510
+ : {}),
1511
+ ...(trustCtx.requesterExternalUserId
1512
+ ? { actorExternalId: trustCtx.requesterExternalUserId }
1513
+ : {}),
1514
+ }
1515
+ : undefined;
1516
+
1420
1517
  // Account identifier threaded into backfill so `resolveConnection()`
1421
1518
  // can pick the right workspace in multi-account setups. Best-effort:
1422
1519
  // the gateway forwards `sourceMetadata.account` when it knows which
@@ -1524,6 +1621,7 @@ export async function handleChannelInbound({
1524
1621
  clientTimezone: inboundClientTimezone,
1525
1622
  slackBotMentioned,
1526
1623
  slackInbound,
1624
+ channelInbound,
1527
1625
  });
1528
1626
  }
1529
1627
  }
@@ -1775,6 +1873,15 @@ async function persistBackfilledSlackMessage(params: {
1775
1873
  );
1776
1874
  const slackTranscriptTimestampTimezone =
1777
1875
  resolveSlackTranscriptTimestampTimezone();
1876
+ const slackTimezoneFields = buildSlackTimezoneMetadata({
1877
+ actorTimezone,
1878
+ actorTimezoneLabel,
1879
+ actorTimezoneOffsetSeconds: message.metadata?.actorTimezoneOffsetSeconds,
1880
+ timestampTimezone: slackTranscriptTimestampTimezone?.timestampTimezone,
1881
+ timestampTimezoneLabel:
1882
+ slackTranscriptTimestampTimezone?.timestampTimezoneLabel,
1883
+ speakerTimezoneLabel: isGuardian ? undefined : actorTimezoneLabel,
1884
+ });
1778
1885
  const slackMeta: SlackMessageMetadata = {
1779
1886
  source: "slack",
1780
1887
  channelId: params.channelId,
@@ -1783,15 +1890,7 @@ async function persistBackfilledSlackMessage(params: {
1783
1890
  ...(message.threadId ? { threadTs: message.threadId } : {}),
1784
1891
  ...(message.sender?.name ? { displayName: message.sender.name } : {}),
1785
1892
  ...(actorExternalUserId ? { actorExternalUserId } : {}),
1786
- ...buildSlackTimezoneMetadata({
1787
- actorTimezone,
1788
- actorTimezoneLabel,
1789
- actorTimezoneOffsetSeconds: message.metadata?.actorTimezoneOffsetSeconds,
1790
- timestampTimezone: slackTranscriptTimestampTimezone?.timestampTimezone,
1791
- timestampTimezoneLabel:
1792
- slackTranscriptTimestampTimezone?.timestampTimezoneLabel,
1793
- speakerTimezoneLabel: isGuardian ? undefined : actorTimezoneLabel,
1794
- }),
1893
+ ...slackTimezoneFields,
1795
1894
  ...(slackFiles.length > 0 ? { slackFiles } : {}),
1796
1895
  };
1797
1896
 
@@ -1813,9 +1912,32 @@ async function persistBackfilledSlackMessage(params: {
1813
1912
  ? message.timestamp
1814
1913
  : undefined;
1815
1914
 
1915
+ // A row the daemon authors carries the neutral envelope, Slack's own fields
1916
+ // riding its passthrough; a person's row keeps Slack's envelope for now.
1917
+ const envelope =
1918
+ role === "assistant"
1919
+ ? {
1920
+ providerMeta: JSON.stringify({
1921
+ source: "slack",
1922
+ conversationExternalId: params.channelId,
1923
+ messageId: message.id,
1924
+ eventKind: "message",
1925
+ ...(message.threadId ? { threadId: message.threadId } : {}),
1926
+ ...(message.sender?.name
1927
+ ? { displayName: message.sender.name }
1928
+ : {}),
1929
+ ...(actorExternalUserId
1930
+ ? { actorExternalId: actorExternalUserId }
1931
+ : {}),
1932
+ ...slackTimezoneFields,
1933
+ ...(slackFiles.length > 0 ? { slackFiles } : {}),
1934
+ } satisfies ProviderMessageMetadata),
1935
+ }
1936
+ : { slackMeta: writeSlackMetadata(slackMeta) };
1937
+
1816
1938
  const persisted = await addMessage(params.conversationId, role, rawText, {
1817
1939
  metadata: {
1818
- slackMeta: writeSlackMetadata(slackMeta),
1940
+ ...envelope,
1819
1941
  ...(sentAt !== undefined ? { sentAt } : {}),
1820
1942
  provenanceTrustClass: isGuardian ? "guardian" : "unknown",
1821
1943
  provenanceSourceChannel: "slack",
@@ -131,9 +131,10 @@ export interface AclEnforcementParams {
131
131
  * The platform named no actor for this event (the id is a channel's
132
132
  * synthetic system identity). There is no identity claim to enforce, so
133
133
  * the ACL neither resolves a member nor denies: the family stage that
134
- * consumes such an event applies it only to rows whose author cleared
135
- * this ACL when the original message arrived. Callers set this ONLY for
136
- * event kinds that cannot start an agent turn.
134
+ * consumes such an event applies it only to a row the daemon already
135
+ * holds for that chat, one ingested from an author who cleared this ACL
136
+ * on arrival or one the assistant posted itself. Callers set this ONLY
137
+ * for event kinds that cannot start an agent turn.
137
138
  */
138
139
  actorUnattributed?: boolean;
139
140
  }
@@ -44,6 +44,7 @@ mock.module("../../../persistence/delivery-channels.js", () => ({
44
44
  mock.module("../../../persistence/delivery-crud.js", () => ({
45
45
  linkMessage: () => {},
46
46
  storeReplyMessageId: (eventId: string, replyMessageId: string) => {
47
+ operationOrder.push("store-reply-id");
47
48
  storedReplyMessageIds.push({ eventId, replyMessageId });
48
49
  },
49
50
  storeStreamedReplyTs: (eventId: string, messageTs: string) => {
@@ -58,6 +59,7 @@ mock.module("../../../persistence/delivery-status.js", () => ({
58
59
  deliveredEvents.push(eventId);
59
60
  },
60
61
  markProcessed: (eventId: string) => {
62
+ operationOrder.push("mark-processed");
61
63
  markedProcessedEvents.push(eventId);
62
64
  },
63
65
  recordDeliveryFailure: (eventId: string) => {
@@ -111,12 +113,35 @@ mock.module("../../../messaging/providers/index.js", () => ({
111
113
  sentActivity.push(target);
112
114
  return setChannelActivityImpl(target);
113
115
  },
116
+ // Slack declares it can stream by implementing the method; whether a given
117
+ // conversation can is Slack's own answer to `start`, which is why the stub
118
+ // below models the refusal rather than gating before the call.
119
+ getTransportForCallback: () => ({
120
+ streamReply: () => undefined,
121
+ streamPersists: true,
122
+ }),
114
123
  sendChannelStreamOp: async (
115
- _callbackUrl: string,
124
+ callbackUrl: string,
116
125
  _chatId: string,
117
126
  op: Record<string, unknown>,
118
127
  ) => {
119
128
  sentStreamOps.push(op);
129
+ // `chat.startStream` streams into a thread, and names the reader when the
130
+ // room has more than one, so a start lacking either is refused. Modelled
131
+ // here so these tests exercise the fallback the refusal produces.
132
+ // `chat.startStream` streams into a thread, and a room with more than one
133
+ // reader must name the reader, so a start lacking either is refused. Slack
134
+ // ids say which room this is: `D` a DM, `C` a channel. Modelled here so
135
+ // these tests exercise the fallback a refusal produces, rather than a gate
136
+ // that no longer exists above the transport.
137
+ if (op.action === "start") {
138
+ if (!callbackUrl.includes("threadTs=")) {
139
+ return { ok: false };
140
+ }
141
+ if (/channel=C/.test(callbackUrl) && op.audience === undefined) {
142
+ return { ok: false };
143
+ }
144
+ }
120
145
  return sendChannelStreamOpImpl(op);
121
146
  },
122
147
  }));
@@ -322,6 +347,37 @@ describe("processChannelMessageInBackground — reply delivery", () => {
322
347
  expect(deliveredEvents).toEqual(["evt-fast-path"]);
323
348
  });
324
349
 
350
+ test("stores the reply id before marking the event processed", async () => {
351
+ // `processed` is the point stranded-delivery recovery starts considering
352
+ // an event, and that step reads the reply id from the stored payload. If
353
+ // the event became eligible first, a crash in between would leave a row
354
+ // that recovery skips, so its reply would never be delivered.
355
+ const processMessage: MessageProcessor = async () => ({
356
+ messageId: "user-msg-order",
357
+ assistantMessageId: "assistant-msg-order",
358
+ });
359
+
360
+ processChannelMessageInBackground({
361
+ processMessage,
362
+ conversationId: "conv-order",
363
+ eventId: "evt-order",
364
+ content: "hello",
365
+ sourceChannel: "slack",
366
+ sourceInterface: "slack",
367
+ externalChatId: "C-ORDER",
368
+ trustCtx,
369
+ metadataHints: [],
370
+ replyCallbackUrl: "https://example.test/deliver/slack?channel=C-ORDER",
371
+ });
372
+
373
+ await flush();
374
+
375
+ expect(operationOrder.indexOf("store-reply-id")).toBeGreaterThanOrEqual(0);
376
+ expect(operationOrder.indexOf("store-reply-id")).toBeLessThan(
377
+ operationOrder.indexOf("mark-processed"),
378
+ );
379
+ });
380
+
325
381
  test("suppresses reply delivery when a deduplicated redelivery's prior attempt already delivered", async () => {
326
382
  const conversationId = "conv-dedup-delivered";
327
383
  const channelId = "C-DEDUP-DELIVERED";
@@ -351,12 +407,15 @@ describe("processChannelMessageInBackground — reply delivery", () => {
351
407
 
352
408
  await flush();
353
409
 
354
- // The redelivery is recorded as processed, but the original reply is not
355
- // re-delivered — no durable delivery, no terminal delivery transition.
410
+ // The redelivery is recorded as processed and nothing is re-posted. It
411
+ // still settles its OWN delivery state: the sibling owns delivery, so a
412
+ // row left `pending` would read as still owing one and the
413
+ // stranded-delivery recovery step would re-post the sibling's reply
414
+ // after a restart.
356
415
  expect(markedProcessedEvents).toEqual(["evt-dedup-delivered"]);
357
416
  expect(replyDeliveryCalls).toEqual([]);
358
- expect(deliveredEvents).toEqual([]);
359
417
  expect(deliveredChannelReplies).toEqual([]);
418
+ expect(deliveredEvents).toEqual(["evt-dedup-delivered"]);
360
419
  });
361
420
 
362
421
  test("skips reply delivery when a deduplicated redelivery's prior attempt failed (sweep owns recovery)", async () => {
@@ -386,10 +445,13 @@ describe("processChannelMessageInBackground — reply delivery", () => {
386
445
 
387
446
  await flush();
388
447
 
448
+ // Same settlement as the delivered-sibling case. Marking THIS row
449
+ // delivered does not touch the sibling's `failed` status, so the sweep
450
+ // still owns and retries the real delivery.
389
451
  expect(markedProcessedEvents).toEqual(["evt-dedup-failed"]);
390
452
  expect(replyDeliveryCalls).toEqual([]);
391
- expect(deliveredEvents).toEqual([]);
392
453
  expect(deliveredChannelReplies).toEqual([]);
454
+ expect(deliveredEvents).toEqual(["evt-dedup-failed"]);
393
455
  });
394
456
 
395
457
  test("recovers the reply when a deduplicated redelivery's prior attempt is stuck pending (crash window)", async () => {
@@ -510,7 +572,8 @@ describe("processChannelMessageInBackground — reply delivery", () => {
510
572
 
511
573
  await flush();
512
574
 
513
- expect(slackStreamOps()).toEqual([]);
575
+ // A start is attempted and refused, so nothing advances past it.
576
+ expect(slackStreamOps().map((op) => op.action)).toEqual(["start"]);
514
577
  expect(
515
578
  deliveredChannelReplies
516
579
  .map((entry) => entry.payload.text)
@@ -566,7 +629,6 @@ describe("processChannelMessageInBackground — reply delivery", () => {
566
629
  expect(slackStreamOps()).toEqual([
567
630
  {
568
631
  action: "start",
569
- anchorMessageId: threadTs,
570
632
  text: "Streamed DM reply.",
571
633
  appended: "Streamed DM reply.",
572
634
  },
@@ -648,7 +710,8 @@ describe("processChannelMessageInBackground — reply delivery", () => {
648
710
  .map((entry) => entry.payload.text)
649
711
  .filter(Boolean),
650
712
  ).toEqual([]);
651
- expect(slackStreamOps()).toEqual([]);
713
+ // A start is attempted and refused, so nothing advances past it.
714
+ expect(slackStreamOps().map((op) => op.action)).toEqual(["start"]);
652
715
  expect(replyDeliveryCalls).toEqual([
653
716
  { messageId: "assistant-msg-channel-final", startFromSegment: 0 },
654
717
  ]);
@@ -889,6 +952,104 @@ describe("processChannelMessageInBackground — admission (queue if busy)", () =
889
952
  expect(markedProcessedEvents).toEqual([]);
890
953
  expect(deliveredEvents).toEqual([]);
891
954
  });
955
+
956
+ test("a busy loss with a caller fallback degrades there, never to the sweep", async () => {
957
+ const processMessage: MessageProcessor = async () => {
958
+ throw new Error(CONVERSATION_BUSY_MESSAGE);
959
+ };
960
+
961
+ let fellBack = 0;
962
+ processChannelMessageInBackground({
963
+ processMessage,
964
+ conversationId: "conv-wake-busy",
965
+ eventId: "evt-wake-busy",
966
+ content: "Alice reacted with :tada: to your message",
967
+ sourceChannel: "discord",
968
+ sourceInterface: "discord",
969
+ externalChatId: "999000111222333444",
970
+ trustCtx,
971
+ metadataHints: [],
972
+ replyCallbackUrl: "https://example.test/deliver/discord",
973
+ onTurnLostToBusy: async () => {
974
+ fellBack++;
975
+ },
976
+ });
977
+
978
+ await flush();
979
+
980
+ // The caller's event stores nothing the sweep could rebuild a turn
981
+ // from, so its processing lane must never see it: the degradation is
982
+ // the fallback alone.
983
+ expect(fellBack).toBe(1);
984
+ expect(deferredRetryEvents).toEqual([]);
985
+ expect(retryableFailureEvents).toEqual([]);
986
+ expect(processingFailureEvents).toEqual([]);
987
+ });
988
+
989
+ test("a non-busy failure with a caller fallback records nothing for the sweep", async () => {
990
+ const processMessage: MessageProcessor = async () => {
991
+ throw new Error("provider exploded");
992
+ };
993
+
994
+ let fellBack = 0;
995
+ processChannelMessageInBackground({
996
+ processMessage,
997
+ conversationId: "conv-wake-error",
998
+ eventId: "evt-wake-error",
999
+ content: "Alice reacted with :tada: to your message",
1000
+ sourceChannel: "discord",
1001
+ sourceInterface: "discord",
1002
+ externalChatId: "999000111222333444",
1003
+ trustCtx,
1004
+ metadataHints: [],
1005
+ replyCallbackUrl: "https://example.test/deliver/discord",
1006
+ onTurnLostToBusy: async () => {
1007
+ fellBack++;
1008
+ },
1009
+ });
1010
+
1011
+ await flush();
1012
+
1013
+ // Best-effort turn lost; a failure record would only feed the sweep an
1014
+ // event it cannot rebuild. The fallback is busy-specific and stays uncalled.
1015
+ expect(fellBack).toBe(0);
1016
+ expect(processingFailureEvents).toEqual([]);
1017
+ expect(deferredRetryEvents).toEqual([]);
1018
+ });
1019
+
1020
+ test("ingress row extensions reach processMessage", async () => {
1021
+ let seenOptions: Record<string, unknown> | undefined;
1022
+ const processMessage: MessageProcessor = async (
1023
+ _conversationId,
1024
+ _content,
1025
+ options,
1026
+ ) => {
1027
+ seenOptions = options as unknown as Record<string, unknown>;
1028
+ return { messageId: "user-row-1" };
1029
+ };
1030
+
1031
+ processChannelMessageInBackground({
1032
+ processMessage,
1033
+ conversationId: "conv-wake-meta",
1034
+ eventId: "evt-wake-meta",
1035
+ content: "Alice reacted with :tada: to your message",
1036
+ sourceChannel: "discord",
1037
+ sourceInterface: "discord",
1038
+ externalChatId: "999000111222333444",
1039
+ trustCtx,
1040
+ metadataHints: [],
1041
+ replyCallbackUrl: "https://example.test/deliver/discord",
1042
+ slackReactionRowMeta: '{"eventKind":"reaction"}',
1043
+ clientMessageId: "reaction:evt-wake-meta",
1044
+ skipUserMessageIndexing: true,
1045
+ });
1046
+
1047
+ await flush();
1048
+
1049
+ expect(seenOptions?.slackReactionRowMeta).toBe('{"eventKind":"reaction"}');
1050
+ expect(seenOptions?.clientMessageId).toBe("reaction:evt-wake-meta");
1051
+ expect(seenOptions?.skipUserMessageIndexing).toBe(true);
1052
+ });
892
1053
  });
893
1054
 
894
1055
  describe("channel activity timing", () => {