@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
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * The intercept reads the reactor's trust solely from
5
5
  * `sourceMetadata.trustVerdict` (via `actorTrustContextFromVerdict`) — no
6
- * local resolver, cache warm, or IPC reads. Pins the four dispositions:
7
- * guardian reactions route into the approval decision pipeline, contact
8
- * reactions are recorded but never approve, and unknown / missing / failed /
6
+ * local resolver, cache warm, or IPC reads. Pins the dispositions: an
7
+ * admitted actor's reaction is recorded against the reacted message's
8
+ * conversation whatever their trust class, and unknown / missing / failed /
9
9
  * contradictory verdicts drop fail-closed before any write.
10
10
  */
11
11
  import { beforeEach, describe, expect, mock, test } from "bun:test";
@@ -70,45 +70,110 @@ mock.module("../../../contacts/contact-store.js", () => ({
70
70
  // ---------------------------------------------------------------------------
71
71
 
72
72
  let recordInboundCalls: Array<{ conversationId?: string }> = [];
73
- let recordedEvent: { eventId: string; conversationId: string } | null = null;
73
+ // Keyed the way the real `channel_inbound_events` row is keyed, so a test can
74
+ // replay a sequence of events and see which of them the daemon treats as new.
75
+ const inboundStore = new Map<
76
+ string,
77
+ { eventId: string; conversationId: string }
78
+ >();
79
+ const inboundKey = (channel: string, chat: string, id: string) =>
80
+ `${channel}\u0000${chat}\u0000${id}`;
81
+ /** Seed a row so the next delivery of `externalMessageId` reads as a redelivery. */
82
+ function seedRecordedEvent(externalMessageId: string, eventId: string): void {
83
+ inboundStore.set(inboundKey("slack", SLACK_CHANNEL_ID, externalMessageId), {
84
+ eventId,
85
+ conversationId: "conv-target",
86
+ });
87
+ }
74
88
  let outboundTargetConversationId: string | null = null;
75
89
  let outboundLookupChannels: string[] = [];
76
90
  let storedTarget: { messageId: string; conversationId: string } | null = null;
77
91
  mock.module("../../../persistence/delivery-crud.js", () => ({
78
92
  recordInbound: (
79
- _channel: string,
80
- _chat: string,
81
- _externalMessageId: string,
93
+ channel: string,
94
+ chat: string,
95
+ externalMessageId: string,
82
96
  options?: { conversationId?: string },
83
97
  ) => {
84
98
  recordInboundCalls.push({ conversationId: options?.conversationId });
85
- return {
86
- eventId: "evt-1",
99
+ const key = inboundKey(channel, chat, externalMessageId);
100
+ const existing = inboundStore.get(key);
101
+ if (existing) {
102
+ return { ...existing, accepted: true, duplicate: true };
103
+ }
104
+ const row = {
105
+ eventId: `evt-${inboundStore.size + 1}`,
87
106
  conversationId: options?.conversationId ?? "conv-minted",
88
- accepted: true,
89
- duplicate: false,
90
107
  };
108
+ inboundStore.set(key, row);
109
+ return { ...row, accepted: true, duplicate: false };
91
110
  },
92
111
  findMessageBySourceId: () => storedTarget,
93
- findConversationByProviderMessageId: (sourceChannel: string) => {
112
+ findMessageByProviderMessageId: (sourceChannel: string) => {
94
113
  outboundLookupChannels.push(sourceChannel);
95
- return outboundTargetConversationId;
114
+ return outboundTargetConversationId
115
+ ? {
116
+ messageId: "outbound-row-1",
117
+ conversationId: outboundTargetConversationId,
118
+ }
119
+ : null;
96
120
  },
97
- findInboundEvent: () => recordedEvent,
121
+ findInboundEvent: (
122
+ channel: string,
123
+ chat: string,
124
+ externalMessageId: string,
125
+ ) => inboundStore.get(inboundKey(channel, chat, externalMessageId)) ?? null,
98
126
  clearPayload: () => {},
127
+ storePayload: (eventId: string, payload: Record<string, unknown>) => {
128
+ storedPayloads.push({ eventId, payload });
129
+ },
99
130
  linkMessage: () => {},
100
131
  }));
132
+ const storedPayloads: Array<{
133
+ eventId: string;
134
+ payload: Record<string, unknown>;
135
+ }> = [];
101
136
 
102
137
  let addMessageCalls = 0;
138
+ let targetRow: {
139
+ role: string;
140
+ content: unknown;
141
+ metadata?: string | null;
142
+ } | null = null;
103
143
  mock.module("../../../persistence/conversation-crud.js", () => ({
104
144
  addMessage: async () => {
105
145
  addMessageCalls++;
106
146
  return { id: "msg-1" };
107
147
  },
148
+ getMessageById: () => targetRow,
108
149
  }));
109
150
 
151
+ let markProcessedCalls = 0;
110
152
  mock.module("../../../persistence/delivery-status.js", () => ({
111
- markProcessed: () => {},
153
+ markProcessed: () => {
154
+ markProcessedCalls++;
155
+ },
156
+ }));
157
+
158
+ let dispatchedTurns: Array<Record<string, unknown>> = [];
159
+ let dispatchThrows = false;
160
+ mock.module("./background-dispatch.js", () => ({
161
+ processChannelMessageInBackground: (params: Record<string, unknown>) => {
162
+ if (dispatchThrows) {
163
+ throw new Error("dispatch setup exploded");
164
+ }
165
+ dispatchedTurns.push(params);
166
+ },
167
+ }));
168
+
169
+ // The intercept imports `processMessage` only to hand it to the dispatch;
170
+ // stub it so the test never drags the daemon turn machinery in at import
171
+ // time.
172
+ mock.module("../../../daemon/process-message.js", () => ({
173
+ processMessage: async () => ({ messageId: "turn-user-row" }),
174
+ }));
175
+ mock.module("../../../daemon/approval-generators.js", () => ({
176
+ createApprovalCopyGenerator: () => ({}),
112
177
  }));
113
178
  mock.module("../../../persistence/external-conversation-store.js", () => ({
114
179
  upsertBinding: () => {},
@@ -120,24 +185,6 @@ mock.module("../../../daemon/disk-pressure-policy.js", () => ({
120
185
  classifyDiskPressureTurnPolicy: () => ({ action: "allow" }),
121
186
  }));
122
187
 
123
- let guardianReplyCalls: Array<{
124
- trustClass: string;
125
- guardianPrincipalId: string | null | undefined;
126
- }> = [];
127
- let guardianReplyResponse: Record<string, unknown> | undefined;
128
- mock.module("./guardian-reply-intercept.js", () => ({
129
- handleGuardianReplyIntercept: async (params: {
130
- trustClass: string;
131
- guardianPrincipalId: string | null | undefined;
132
- }) => {
133
- guardianReplyCalls.push({
134
- trustClass: params.trustClass,
135
- guardianPrincipalId: params.guardianPrincipalId,
136
- });
137
- return { response: guardianReplyResponse };
138
- },
139
- }));
140
-
141
188
  import { handleReactionIntercept } from "./reaction-intercept.js";
142
189
 
143
190
  // ---------------------------------------------------------------------------
@@ -179,23 +226,34 @@ let msgCounter = 0;
179
226
  function buildParams(overrides: {
180
227
  rawSenderId: string;
181
228
  trustVerdict?: TrustVerdict;
229
+ op?: "added" | "removed";
230
+ externalMessageId?: string;
231
+ sourceChannel?: "slack" | "discord";
182
232
  }) {
183
233
  msgCounter++;
234
+ const sourceChannel = overrides.sourceChannel ?? ("slack" as const);
184
235
  return {
185
236
  reaction: {
186
- op: "added" as const,
237
+ op: overrides.op ?? ("added" as const),
187
238
  emoji: "white_check_mark",
239
+ emojiKind: "shortcode" as const,
240
+ emojiName: "white_check_mark",
188
241
  targetMessageId: "1700000000.1",
189
242
  },
190
- sourceChannel: "slack" as const,
191
- sourceInterface: "slack" as const,
243
+ sourceChannel,
244
+ sourceInterface: sourceChannel,
192
245
  conversationExternalId: SLACK_CHANNEL_ID,
193
- externalMessageId: `${SLACK_CHANNEL_ID}:1700000000.1:${msgCounter}`,
246
+ externalMessageId:
247
+ overrides.externalMessageId ??
248
+ `${SLACK_CHANNEL_ID}:1700000000.1:${msgCounter}`,
194
249
  rawSenderId: overrides.rawSenderId,
195
250
  canonicalSenderId: overrides.rawSenderId,
196
251
  actorDisplayName: "Reactor",
197
252
  actorUsername: undefined,
198
- replyCallbackUrl: "http://localhost:7830/deliver/slack",
253
+ // The shape the gateway actually builds for a reaction: the reacted
254
+ // message's own ts stands in as `threadTs`, which is what the wake has
255
+ // to correct before delivering a reply.
256
+ replyCallbackUrl: `http://localhost:7830/deliver/slack?channel=${SLACK_CHANNEL_ID}&threadTs=1700000000.1`,
199
257
  sourceMetadata: {
200
258
  messageId: "1700000000.1",
201
259
  chatType: "channel",
@@ -203,38 +261,43 @@ function buildParams(overrides: {
203
261
  ? { trustVerdict: overrides.trustVerdict }
204
262
  : {}),
205
263
  } as never,
206
- approvalConversationGenerator: undefined,
207
264
  };
208
265
  }
209
266
 
210
267
  function expectDropped(result: Record<string, unknown>): void {
211
268
  expect(result.reaction).toBe("dropped_unknown_actor");
212
- // Dropped before any write or routing: no dedup record, no transcript row,
213
- // no approval-pipeline dispatch.
269
+ // Dropped before any write: no dedup record and no transcript row.
214
270
  expect(recordInboundCalls.length).toBe(0);
215
271
  expect(addMessageCalls).toBe(0);
216
- expect(guardianReplyCalls.length).toBe(0);
217
272
  }
218
273
 
219
- describe("reaction intercept consumes the stamped verdict directly", () => {
220
- beforeEach(() => {
221
- ipcCalls = [];
222
- guardianDeliveryReads = 0;
223
- setMemberVerdictCalls = 0;
224
- contactLookups = 0;
225
- recordInboundCalls = [];
226
- recordedEvent = null;
227
- outboundTargetConversationId = null;
228
- outboundLookupChannels = [];
229
- addMessageCalls = 0;
230
- guardianReplyCalls = [];
231
- guardianReplyResponse = undefined;
232
- storedTarget = { messageId: "msg-target", conversationId: "conv-target" };
233
- });
274
+ function resetHarness(): void {
275
+ ipcCalls = [];
276
+ guardianDeliveryReads = 0;
277
+ setMemberVerdictCalls = 0;
278
+ contactLookups = 0;
279
+ recordInboundCalls = [];
280
+ inboundStore.clear();
281
+ outboundTargetConversationId = null;
282
+ outboundLookupChannels = [];
283
+ addMessageCalls = 0;
284
+ storedTarget = { messageId: "msg-target", conversationId: "conv-target" };
285
+ // Default target: a user-authored row, so reactions stay passive unless
286
+ // a test makes the target the assistant's own post.
287
+ targetRow = {
288
+ role: "user",
289
+ content: [{ type: "text", text: "the reacted-to message" }],
290
+ };
291
+ dispatchedTurns = [];
292
+ dispatchThrows = false;
293
+ storedPayloads.length = 0;
294
+ markProcessedCalls = 0;
295
+ }
234
296
 
235
- test("guardian verdict routes the reaction into the approval decision pipeline", async () => {
236
- guardianReplyResponse = { accepted: true, canonicalRouter: "applied" };
297
+ describe("reaction intercept consumes the stamped verdict directly", () => {
298
+ beforeEach(resetHarness);
237
299
 
300
+ test("a guardian's reaction is a transcript row, not an approval decision", async () => {
238
301
  const result = await handleReactionIntercept(
239
302
  buildParams({
240
303
  rawSenderId: GUARDIAN_USER_ID,
@@ -242,15 +305,15 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
242
305
  }),
243
306
  );
244
307
 
245
- expect(guardianReplyCalls).toEqual([
246
- { trustClass: "guardian", guardianPrincipalId: "principal-guardian-1" },
247
- ]);
248
- // Consumed as a guardian decision — short-circuits before persistence.
249
- expect(result).toEqual(guardianReplyResponse);
250
- expect(addMessageCalls).toBe(0);
308
+ // A guardian reacts like any other admitted actor: the emoji annotates
309
+ // the room. Approvals are answered by the card's buttons.
310
+ expect(result.accepted).toBe(true);
311
+ expect(result.reaction).toBeUndefined();
312
+ expect(addMessageCalls).toBe(1);
313
+ expect(recordInboundCalls).toEqual([{ conversationId: "conv-target" }]);
251
314
  });
252
315
 
253
- test("contact verdict records the reaction; the decision pipeline self-gate ignores it", async () => {
316
+ test("a contact's reaction is recorded in the reacted message's conversation", async () => {
254
317
  const result = await handleReactionIntercept(
255
318
  buildParams({
256
319
  rawSenderId: MEMBER_USER_ID,
@@ -258,11 +321,6 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
258
321
  }),
259
322
  );
260
323
 
261
- // Dispatched with the contact class (guardian-reply-intercept self-gates
262
- // on guardian), then falls through to transcript persistence.
263
- expect(guardianReplyCalls).toEqual([
264
- { trustClass: "trusted_contact", guardianPrincipalId: undefined },
265
- ]);
266
324
  expect(result.accepted).toBe(true);
267
325
  expect(result.reaction).toBeUndefined();
268
326
  expect(addMessageCalls).toBe(1);
@@ -270,10 +328,14 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
270
328
  expect(recordInboundCalls).toEqual([{ conversationId: "conv-target" }]);
271
329
  });
272
330
 
273
- test("a reaction on the assistant's own post resolves through its stored envelope", async () => {
331
+ test("a reaction added to the assistant's own post wakes a discretion turn", async () => {
274
332
  // Outbound posts open no inbound event, so the id is only on the row.
275
333
  storedTarget = null;
276
334
  outboundTargetConversationId = "conv-assistant-post";
335
+ targetRow = {
336
+ role: "assistant",
337
+ content: [{ type: "text", text: "Deploy finished cleanly." }],
338
+ };
277
339
 
278
340
  const result = await handleReactionIntercept(
279
341
  buildParams({
@@ -285,16 +347,40 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
285
347
  expect(recordInboundCalls).toEqual([
286
348
  { conversationId: "conv-assistant-post" },
287
349
  ]);
288
- expect(addMessageCalls).toBe(1);
289
- expect(result).toMatchObject({ accepted: true, duplicate: false });
290
350
  // The outbound-row lookup is scoped to the reaction's own channel: a
291
351
  // Slack reaction never scans another channel's conversations.
292
352
  expect(outboundLookupChannels).toEqual(["slack"]);
353
+ expect(result).toMatchObject({
354
+ accepted: true,
355
+ duplicate: false,
356
+ reaction: "wake_dispatched",
357
+ });
358
+ // The turn's own persisted row is the reaction row: no passive write,
359
+ // and the event is settled up front (wake turns opt out of sweep replay).
360
+ expect(addMessageCalls).toBe(0);
361
+ expect(markProcessedCalls).toBe(1);
362
+ expect(dispatchedTurns).toHaveLength(1);
363
+ const turn = dispatchedTurns[0];
364
+ expect(turn.conversationId).toBe("conv-assistant-post");
365
+ expect(turn.clientMessageId).toBe("reaction:evt-1");
366
+ expect(turn.skipUserMessageIndexing).toBe(true);
367
+ // The model's turn content is the same line the reload renderer
368
+ // produces, with the quoted target resolved.
369
+ expect(String(turn.content)).toContain("reacted with");
370
+ expect(String(turn.content)).toContain("Deploy finished cleanly.");
371
+ // The row's envelope matches what the passive path would have written,
372
+ // riding the transitional Slack-only carrier.
373
+ expect(typeof turn.slackReactionRowMeta).toBe("string");
374
+ expect(turn.channelInbound).toBeUndefined();
293
375
  });
294
376
 
295
- test("a non-Slack reaction resolves the assistant's post on its own channel", async () => {
377
+ test("a non-Slack reaction on the assistant's post wakes with the neutral envelope", async () => {
296
378
  storedTarget = null;
297
379
  outboundTargetConversationId = "conv-discord-post";
380
+ targetRow = {
381
+ role: "assistant",
382
+ content: [{ type: "text", text: "Deploy finished cleanly." }],
383
+ };
298
384
 
299
385
  const params = {
300
386
  ...buildParams({
@@ -302,15 +388,186 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
302
388
  trustVerdict: MEMBER_VERDICT,
303
389
  }),
304
390
  sourceChannel: "discord" as const,
305
- sourceInterface: undefined,
391
+ sourceInterface: "discord" as const,
306
392
  };
307
393
  const result = await handleReactionIntercept(params);
308
394
 
309
395
  expect(outboundLookupChannels).toEqual(["discord"]);
310
- expect(recordInboundCalls).toEqual([
311
- { conversationId: "conv-discord-post" },
312
- ]);
313
- expect(result).toMatchObject({ accepted: true, duplicate: false });
396
+ expect(result).toMatchObject({
397
+ accepted: true,
398
+ duplicate: false,
399
+ reaction: "wake_dispatched",
400
+ });
401
+ expect(dispatchedTurns).toHaveLength(1);
402
+ // Non-Slack wake turns ride the ordinary neutral-envelope carrier, the
403
+ // same lane inbound messages use.
404
+ const channelInbound = dispatchedTurns[0].channelInbound as Record<
405
+ string,
406
+ unknown
407
+ >;
408
+ expect(channelInbound.eventKind).toBe("reaction");
409
+ expect(channelInbound.source).toBe("discord");
410
+ expect(dispatchedTurns[0].slackReactionRowMeta).toBeUndefined();
411
+ });
412
+
413
+ test("a Slack wake reply targets the thread the reacted message lives in", async () => {
414
+ // The reaction event's callback names the REACTED MESSAGE's ts as its
415
+ // thread (Slack gives a reaction no thread of its own), so delivering
416
+ // through it unchanged would root the reply at that message instead of
417
+ // in the conversation's own thread.
418
+ storedTarget = null;
419
+ outboundTargetConversationId = "conv-assistant-post";
420
+ targetRow = {
421
+ role: "assistant",
422
+ content: [{ type: "text", text: "Deploy finished cleanly." }],
423
+ metadata: JSON.stringify({
424
+ slackMeta: JSON.stringify({
425
+ source: "slack",
426
+ channelId: SLACK_CHANNEL_ID,
427
+ channelTs: "1700000000.1",
428
+ threadTs: "1699999999.9",
429
+ eventKind: "message",
430
+ }),
431
+ }),
432
+ };
433
+
434
+ await handleReactionIntercept(
435
+ buildParams({
436
+ rawSenderId: MEMBER_USER_ID,
437
+ trustVerdict: MEMBER_VERDICT,
438
+ }),
439
+ );
440
+
441
+ const url = new URL(dispatchedTurns[0].replyCallbackUrl as string);
442
+ // Replaced, not merely present: the event arrived naming 1700000000.1.
443
+ expect(url.searchParams.get("threadTs")).toBe("1699999999.9");
444
+ expect(url.searchParams.get("channel")).toBe(SLACK_CHANNEL_ID);
445
+ // The stored delivery payload names the same corrected destination.
446
+ expect(
447
+ new URL(
448
+ storedPayloads[0].payload.replyCallbackUrl as string,
449
+ ).searchParams.get("threadTs"),
450
+ ).toBe("1699999999.9");
451
+ });
452
+
453
+ test("a Slack wake reply on a channel-root post carries no thread", async () => {
454
+ storedTarget = null;
455
+ outboundTargetConversationId = "conv-assistant-post";
456
+ targetRow = {
457
+ role: "assistant",
458
+ content: [{ type: "text", text: "Deploy finished cleanly." }],
459
+ metadata: JSON.stringify({
460
+ slackMeta: JSON.stringify({
461
+ source: "slack",
462
+ channelId: SLACK_CHANNEL_ID,
463
+ channelTs: "1700000000.1",
464
+ eventKind: "message",
465
+ }),
466
+ }),
467
+ };
468
+
469
+ await handleReactionIntercept(
470
+ buildParams({
471
+ rawSenderId: MEMBER_USER_ID,
472
+ trustVerdict: MEMBER_VERDICT,
473
+ }),
474
+ );
475
+
476
+ const url = new URL(dispatchedTurns[0].replyCallbackUrl as string);
477
+ // The target sits at the channel root, so the reply belongs there too,
478
+ // never in a thread rooted at the reacted message.
479
+ expect(url.searchParams.get("threadTs")).toBeNull();
480
+ expect(url.searchParams.get("channel")).toBe(SLACK_CHANNEL_ID);
481
+ });
482
+
483
+ test("the wake stores a delivery-only payload, never a replayable turn", async () => {
484
+ storedTarget = null;
485
+ outboundTargetConversationId = "conv-assistant-post";
486
+ targetRow = {
487
+ role: "assistant",
488
+ content: [{ type: "text", text: "Deploy finished cleanly." }],
489
+ };
490
+
491
+ await handleReactionIntercept(
492
+ buildParams({
493
+ rawSenderId: MEMBER_USER_ID,
494
+ trustVerdict: MEMBER_VERDICT,
495
+ }),
496
+ );
497
+
498
+ expect(storedPayloads).toHaveLength(1);
499
+ const payload = storedPayloads[0].payload;
500
+ // Enough for the delivery lane to re-post a generated reply...
501
+ expect(typeof payload.replyCallbackUrl).toBe("string");
502
+ expect(payload.externalChatId).toBe(SLACK_CHANNEL_ID);
503
+ // ...and structurally incapable of being replayed as a message turn.
504
+ expect(payload.content).toBeUndefined();
505
+ expect(payload.sourceChannel).toBeUndefined();
506
+ });
507
+
508
+ test("a wake whose dispatch fails to start still records the passive row", async () => {
509
+ storedTarget = null;
510
+ outboundTargetConversationId = "conv-assistant-post";
511
+ targetRow = {
512
+ role: "assistant",
513
+ content: [{ type: "text", text: "Deploy finished cleanly." }],
514
+ };
515
+ dispatchThrows = true;
516
+
517
+ const result = await handleReactionIntercept(
518
+ buildParams({
519
+ rawSenderId: MEMBER_USER_ID,
520
+ trustVerdict: MEMBER_VERDICT,
521
+ }),
522
+ );
523
+
524
+ // The dedup record already exists, so a gateway retry would short-circuit;
525
+ // the reaction must not vanish because the turn could not start.
526
+ expect(addMessageCalls).toBe(1);
527
+ expect(result.reaction).toBeUndefined();
528
+ expect(result).toMatchObject({ accepted: true });
529
+ });
530
+
531
+ test("a reaction REMOVED from the assistant's post stays passive", async () => {
532
+ storedTarget = null;
533
+ outboundTargetConversationId = "conv-assistant-post";
534
+ targetRow = {
535
+ role: "assistant",
536
+ content: [{ type: "text", text: "Deploy finished cleanly." }],
537
+ };
538
+
539
+ const result = await handleReactionIntercept(
540
+ buildParams({
541
+ rawSenderId: MEMBER_USER_ID,
542
+ trustVerdict: MEMBER_VERDICT,
543
+ op: "removed",
544
+ }),
545
+ );
546
+
547
+ expect(dispatchedTurns).toHaveLength(0);
548
+ expect(addMessageCalls).toBe(1);
549
+ expect(result.reaction).toBeUndefined();
550
+ });
551
+
552
+ test("losing the lock after admission degrades the wake to the passive row", async () => {
553
+ storedTarget = null;
554
+ outboundTargetConversationId = "conv-assistant-post";
555
+ targetRow = {
556
+ role: "assistant",
557
+ content: [{ type: "text", text: "Deploy finished cleanly." }],
558
+ };
559
+
560
+ await handleReactionIntercept(
561
+ buildParams({
562
+ rawSenderId: MEMBER_USER_ID,
563
+ trustVerdict: MEMBER_VERDICT,
564
+ }),
565
+ );
566
+ expect(addMessageCalls).toBe(0);
567
+ const fallback = dispatchedTurns[0].onTurnLostToBusy as () => Promise<void>;
568
+ await fallback();
569
+ // Exactly the row the passive path would have written.
570
+ expect(addMessageCalls).toBe(1);
314
571
  });
315
572
 
316
573
  test("a reaction on a message that is not stored is dropped without minting", async () => {
@@ -328,8 +585,21 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
328
585
  expect(addMessageCalls).toBe(0);
329
586
  });
330
587
 
331
- test("a redelivered reaction never reaches the guardian rail twice", async () => {
332
- recordedEvent = { eventId: "evt-1", conversationId: "conv-target" };
588
+ test("a reaction on a guardian card neither records nor wakes", async () => {
589
+ // A legacy channel delivery paired a message row before cards became
590
+ // projection-only, so such rows are still stored. The row is
591
+ // assistant-authored, so without the guardian-card gate the wake
592
+ // predicate would read a reaction on it as a signal addressed to the
593
+ // assistant. Content is shaped the way `isGuardianCardRow` reads it.
594
+ targetRow = {
595
+ role: "assistant",
596
+ content: [
597
+ {
598
+ type: "ui_surface",
599
+ surfaceId: "tool-approval-req-legacy-1",
600
+ },
601
+ ],
602
+ };
333
603
 
334
604
  const result = await handleReactionIntercept(
335
605
  buildParams({
@@ -338,28 +608,30 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
338
608
  }),
339
609
  );
340
610
 
341
- expect(result).toEqual({
342
- accepted: true,
343
- duplicate: true,
344
- eventId: "evt-1",
345
- });
346
- expect(guardianReplyCalls.length).toBe(0);
347
- expect(addMessageCalls).toBe(0);
611
+ expect(result.reaction).toBe("dropped_guardian_card");
612
+ expect(dispatchedTurns.length).toBe(0);
348
613
  expect(recordInboundCalls.length).toBe(0);
614
+ expect(addMessageCalls).toBe(0);
349
615
  });
350
616
 
351
- test("a guardian card reaction still applies when the card is not a stored message", async () => {
352
- storedTarget = null;
353
- guardianReplyResponse = { accepted: true, canonicalRouter: "applied" };
617
+ test("a redelivered reaction is answered once, before any lookup", async () => {
618
+ const externalMessageId = `${SLACK_CHANNEL_ID}:1700000000.1:redelivered`;
619
+ seedRecordedEvent(externalMessageId, "evt-1");
354
620
 
355
621
  const result = await handleReactionIntercept(
356
622
  buildParams({
357
623
  rawSenderId: GUARDIAN_USER_ID,
358
624
  trustVerdict: GUARDIAN_VERDICT,
625
+ externalMessageId,
359
626
  }),
360
627
  );
361
628
 
362
- expect(result).toEqual(guardianReplyResponse);
629
+ expect(result).toEqual({
630
+ accepted: true,
631
+ duplicate: true,
632
+ eventId: "evt-1",
633
+ });
634
+ expect(addMessageCalls).toBe(0);
363
635
  expect(recordInboundCalls.length).toBe(0);
364
636
  });
365
637
 
@@ -402,7 +674,6 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
402
674
  });
403
675
 
404
676
  test("classification is verdict-only: no IPC, cache, or local-store reads", async () => {
405
- guardianReplyResponse = { accepted: true, canonicalRouter: "applied" };
406
677
  await handleReactionIntercept(
407
678
  buildParams({
408
679
  rawSenderId: GUARDIAN_USER_ID,
@@ -422,3 +693,92 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
422
693
  expect(contactLookups).toBe(0);
423
694
  });
424
695
  });
696
+
697
+ /**
698
+ * Add, remove, re-add: the sequence whose third event must survive dedup.
699
+ *
700
+ * The intercept returns before `persistPassively` on a duplicate, so an id
701
+ * that repeats across occurrences costs the re-add its transcript row and
702
+ * leaves the removal as the last recorded state. The ids here are the shapes
703
+ * the gateway normalizers build (`slack/reaction-normalizer.ts`,
704
+ * `discord/normalize.ts`), whose per-event component is the trailing segment.
705
+ */
706
+ describe("a re-added reaction records again", () => {
707
+ const SLACK_IDS = [
708
+ `${SLACK_CHANNEL_ID}:1700000000.1:white_check_mark:${MEMBER_USER_ID}:Ev001`,
709
+ `${SLACK_CHANNEL_ID}:1700000000.1:white_check_mark:${MEMBER_USER_ID}:removed:Ev002`,
710
+ `${SLACK_CHANNEL_ID}:1700000000.1:white_check_mark:${MEMBER_USER_ID}:Ev003`,
711
+ ];
712
+ const DISCORD_IDS = [
713
+ `1700000000.1:reaction:\u2705:${MEMBER_USER_ID}:dispatch-0`,
714
+ `1700000000.1:reaction:\u2705:${MEMBER_USER_ID}:removed:dispatch-1`,
715
+ `1700000000.1:reaction:\u2705:${MEMBER_USER_ID}:dispatch-2`,
716
+ ];
717
+
718
+ beforeEach(resetHarness);
719
+
720
+ async function replay(
721
+ externalMessageIds: string[],
722
+ ops: Array<"added" | "removed">,
723
+ sourceChannel: "slack" | "discord" = "slack",
724
+ ) {
725
+ const results: Array<Record<string, unknown>> = [];
726
+ for (const [index, externalMessageId] of externalMessageIds.entries()) {
727
+ results.push(
728
+ await handleReactionIntercept(
729
+ buildParams({
730
+ rawSenderId: MEMBER_USER_ID,
731
+ trustVerdict: MEMBER_VERDICT,
732
+ op: ops[index],
733
+ externalMessageId,
734
+ sourceChannel,
735
+ }),
736
+ ),
737
+ );
738
+ }
739
+ return results;
740
+ }
741
+
742
+ test("Slack: three events, three transcript rows, none deduped away", async () => {
743
+ const results = await replay(SLACK_IDS, ["added", "removed", "added"]);
744
+
745
+ expect(results.map((result) => result.duplicate)).toEqual([
746
+ false,
747
+ false,
748
+ false,
749
+ ]);
750
+ expect(inboundStore.size).toBe(3);
751
+ expect(addMessageCalls).toBe(3);
752
+ });
753
+
754
+ test("Discord: three events, three transcript rows, none deduped away", async () => {
755
+ const results = await replay(
756
+ DISCORD_IDS,
757
+ ["added", "removed", "added"],
758
+ "discord",
759
+ );
760
+
761
+ expect(results.map((result) => result.duplicate)).toEqual([
762
+ false,
763
+ false,
764
+ false,
765
+ ]);
766
+ expect(inboundStore.size).toBe(3);
767
+ expect(addMessageCalls).toBe(3);
768
+ });
769
+
770
+ test("a genuine redelivery of one event still collapses to one", async () => {
771
+ // What the dedup id is for: the gateway retries a forward with the same
772
+ // payload, so the same id arrives twice and the second writes nothing.
773
+ const [first, second] = await replay(
774
+ [SLACK_IDS[0]!, SLACK_IDS[0]!],
775
+ ["added", "added"],
776
+ );
777
+
778
+ expect(first!.duplicate).toBe(false);
779
+ expect(second!.duplicate).toBe(true);
780
+ expect(second!.eventId).toBe(first!.eventId);
781
+ expect(inboundStore.size).toBe(1);
782
+ expect(addMessageCalls).toBe(1);
783
+ });
784
+ });