@vellumai/assistant 0.11.1 → 0.11.2-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 (318) hide show
  1. package/Dockerfile +1 -3
  2. package/README.md +1 -1
  3. package/eslint.config.mjs +28 -8
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  12. package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  13. package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
  14. package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
  15. package/openapi.yaml +292 -5
  16. package/package.json +1 -1
  17. package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
  18. package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
  19. package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
  20. package/src/__tests__/compaction-events.test.ts +139 -2
  21. package/src/__tests__/config-loader-backfill.test.ts +35 -3
  22. package/src/__tests__/config-schema.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +180 -7
  25. package/src/__tests__/conversation-error.test.ts +211 -4
  26. package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
  27. package/src/__tests__/conversation-queue.test.ts +126 -0
  28. package/src/__tests__/conversation-retry-route.test.ts +46 -7
  29. package/src/__tests__/conversation-slash.test.ts +4 -2
  30. package/src/__tests__/conversation-summarize-route.test.ts +7 -1
  31. package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
  32. package/src/__tests__/document-create-dedupe.test.ts +2 -1
  33. package/src/__tests__/document-find-replace.test.ts +2 -1
  34. package/src/__tests__/document-tool-security.test.ts +2 -1
  35. package/src/__tests__/document-update-default-surface.test.ts +2 -1
  36. package/src/__tests__/document-workspace-file.test.ts +467 -0
  37. package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
  38. package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
  39. package/src/__tests__/http-user-message-parity.test.ts +66 -0
  40. package/src/__tests__/list-messages-attachments.test.ts +87 -0
  41. package/src/__tests__/list-messages-provider-error.test.ts +143 -0
  42. package/src/__tests__/list-messages-system-card.test.ts +100 -0
  43. package/src/__tests__/llm-resolver.test.ts +19 -9
  44. package/src/__tests__/managed-profile-guard.test.ts +23 -0
  45. package/src/__tests__/notification-platform-adapter.test.ts +130 -2
  46. package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
  47. package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
  48. package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
  49. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  50. package/src/__tests__/provider-error-scenarios.test.ts +140 -0
  51. package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
  52. package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
  53. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
  54. package/src/__tests__/skills.test.ts +27 -0
  55. package/src/__tests__/slack-channels-routes.test.ts +0 -2
  56. package/src/__tests__/slack-share-routes.test.ts +0 -3
  57. package/src/__tests__/slack-users-routes.test.ts +0 -2
  58. package/src/__tests__/subagent-tools.test.ts +11 -0
  59. package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
  60. package/src/__tests__/tool-result-spool.test.ts +5 -4
  61. package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
  62. package/src/__tests__/turn-events-store.test.ts +26 -0
  63. package/src/__tests__/ui-visual-surface.test.ts +695 -0
  64. package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
  65. package/src/__tests__/visible-app-context.test.ts +189 -0
  66. package/src/__tests__/workspace-git-service.test.ts +173 -33
  67. package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
  68. package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
  69. package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
  70. package/src/agent/loop.ts +36 -7
  71. package/src/api/events/context-window-usage.ts +31 -0
  72. package/src/api/events/notification-intent.ts +8 -0
  73. package/src/api/events/ui-surface-pending.ts +35 -0
  74. package/src/api/index.ts +14 -0
  75. package/src/api/responses/conversation-message.ts +25 -4
  76. package/src/api/surfaces.ts +90 -1
  77. package/src/approvals/guardian-card-withdrawal.ts +66 -31
  78. package/src/approvals/guardian-decision-primitive.ts +4 -0
  79. package/src/calls/__tests__/call-setup-router.test.ts +156 -26
  80. package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
  81. package/src/calls/call-setup-router.ts +80 -36
  82. package/src/calls/voice-session-bridge.ts +173 -26
  83. package/src/cli/commands/inference.help.ts +3 -3
  84. package/src/cli/commands/notifications.help.ts +3 -2
  85. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
  86. package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
  87. package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
  88. package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
  89. package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
  90. package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
  91. package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
  92. package/src/cli/commands/platform/index.help.ts +92 -0
  93. package/src/cli/commands/platform/index.ts +7 -0
  94. package/src/cli/commands/platform/invoices.ts +132 -0
  95. package/src/cli/commands/usage.help.ts +1 -1
  96. package/src/cli/lib/list-installed-plugins.ts +2 -1
  97. package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
  98. package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
  99. package/src/config/assistant-feature-flags.ts +7 -2
  100. package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
  101. package/src/config/bundled-skills/subagent/SKILL.md +3 -1
  102. package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
  103. package/src/config/bundled-skills/visualize/SKILL.md +163 -0
  104. package/src/config/call-site-defaults.ts +3 -2
  105. package/src/config/default-profile-catalog.ts +75 -70
  106. package/src/config/default-profile-names.ts +13 -28
  107. package/src/config/env-registry.ts +1 -0
  108. package/src/config/feature-flag-registry.json +16 -0
  109. package/src/config/llm-resolver.ts +21 -5
  110. package/src/config/loader.ts +18 -11
  111. package/src/config/schemas/llm.ts +1 -9
  112. package/src/config/schemas/memory-retrospective.ts +9 -0
  113. package/src/config/schemas/monitoring.ts +28 -2
  114. package/src/config/schemas/workspace-git.ts +15 -0
  115. package/src/config/seed-inference-profiles.ts +19 -5
  116. package/src/context/post-turn-tool-result-truncation.ts +2 -2
  117. package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
  118. package/src/conversations/message-consolidation.ts +17 -15
  119. package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
  120. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
  121. package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
  122. package/src/daemon/conversation-agent-loop.ts +107 -22
  123. package/src/daemon/conversation-error.ts +169 -48
  124. package/src/daemon/conversation-messaging.ts +87 -4
  125. package/src/daemon/conversation-process.ts +57 -45
  126. package/src/daemon/conversation-runtime-assembly.ts +57 -0
  127. package/src/daemon/conversation-store.ts +36 -1
  128. package/src/daemon/conversation-surfaces.ts +68 -6
  129. package/src/daemon/conversation-turn-finalize.ts +76 -18
  130. package/src/daemon/conversation.ts +122 -27
  131. package/src/daemon/lifecycle.ts +9 -0
  132. package/src/daemon/message-types/conversations.ts +8 -0
  133. package/src/daemon/message-types/surfaces.ts +3 -0
  134. package/src/documents/document-store.ts +247 -10
  135. package/src/home/__tests__/feed-types.test.ts +43 -8
  136. package/src/home/__tests__/feed-writer.test.ts +59 -0
  137. package/src/home/feed-types.ts +34 -7
  138. package/src/home/feed-writer.ts +15 -6
  139. package/src/live-voice/__tests__/activity-label.test.ts +95 -0
  140. package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
  141. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
  142. package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
  143. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
  144. package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
  145. package/src/live-voice/activity-label.ts +169 -0
  146. package/src/live-voice/live-activity-reporter.ts +35 -6
  147. package/src/live-voice/live-voice-session.ts +282 -41
  148. package/src/live-voice/protocol.ts +35 -0
  149. package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
  150. package/src/messaging/providers/slack/adapter.ts +86 -19
  151. package/src/messaging/providers/slack/api.test.ts +85 -1
  152. package/src/messaging/providers/slack/api.ts +121 -288
  153. package/src/messaging/providers/slack/client.ts +20 -251
  154. package/src/messaging/providers/slack/send.test.ts +4 -9
  155. package/src/messaging/providers/slack/send.ts +1 -1
  156. package/src/messaging/providers/slack/types.ts +5 -0
  157. package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
  158. package/src/messaging/providers/slack/web-api-transport.ts +367 -0
  159. package/src/messaging/providers/slack/withdraw.ts +6 -14
  160. package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
  161. package/src/messaging/providers/telegram-bot/send.ts +23 -2
  162. package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
  163. package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
  164. package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
  165. package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
  166. package/src/monitoring/file-descriptors.ts +262 -0
  167. package/src/monitoring/process-memory.ts +4 -11
  168. package/src/monitoring/resource-sampler.ts +5 -0
  169. package/src/monitoring/worker.ts +12 -0
  170. package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
  171. package/src/notifications/__tests__/broadcaster.test.ts +347 -7
  172. package/src/notifications/__tests__/copy-composer.test.ts +53 -3
  173. package/src/notifications/__tests__/decision-engine.test.ts +349 -29
  174. package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
  175. package/src/notifications/__tests__/edit-notification.test.ts +330 -0
  176. package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
  177. package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
  178. package/src/notifications/adapters/macos.ts +3 -0
  179. package/src/notifications/adapters/platform.ts +90 -14
  180. package/src/notifications/adapters/telegram.ts +6 -4
  181. package/src/notifications/assistant-reply-producer.ts +219 -0
  182. package/src/notifications/broadcaster.ts +523 -317
  183. package/src/notifications/copy-composer.ts +27 -6
  184. package/src/notifications/decision-engine.ts +154 -77
  185. package/src/notifications/deterministic-checks.ts +6 -7
  186. package/src/notifications/edit-notification.ts +8 -4
  187. package/src/notifications/emit-signal.ts +49 -16
  188. package/src/notifications/guardian-delivery-recorder.ts +8 -2
  189. package/src/notifications/home-feed-side-effect.ts +43 -6
  190. package/src/notifications/notification-utils.ts +39 -4
  191. package/src/notifications/signal.ts +10 -0
  192. package/src/notifications/types.ts +17 -0
  193. package/src/persistence/bookmark-crud.ts +18 -9
  194. package/src/persistence/conversation-attention-store.ts +26 -0
  195. package/src/persistence/conversation-crud.ts +79 -32
  196. package/src/persistence/conversation-queries.ts +30 -15
  197. package/src/persistence/conversation-title-service.ts +5 -170
  198. package/src/persistence/conversation-types.ts +192 -1
  199. package/src/persistence/db-init.ts +14 -1
  200. package/src/persistence/db-maintenance.ts +5 -4
  201. package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
  202. package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
  203. package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
  204. package/src/persistence/embeddings/embedding-local.ts +2 -1
  205. package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
  206. package/src/persistence/embeddings/plugin-index.ts +84 -12
  207. package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
  208. package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
  209. package/src/persistence/planner-statistics.ts +14 -0
  210. package/src/persistence/schema/documents.ts +26 -11
  211. package/src/persistence/steps.ts +2 -0
  212. package/src/platform/client.test.ts +172 -2
  213. package/src/platform/client.ts +178 -55
  214. package/src/plugin-api/conversation-turn.ts +13 -0
  215. package/src/plugin-api/model-profiles.test.ts +6 -2
  216. package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
  217. package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
  218. package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
  219. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
  220. package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
  221. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
  222. package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
  223. package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
  224. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
  225. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
  226. package/src/plugins/defaults/memory/host-utils.ts +5 -0
  227. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
  228. package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
  229. package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
  230. package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
  231. package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
  232. package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
  233. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
  234. package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
  235. package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
  236. package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
  237. package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
  238. package/src/plugins/defaults/memory/v2/injection.ts +48 -3
  239. package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
  240. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
  241. package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
  242. package/src/plugins/defaults/turn-context/injectors.ts +1 -0
  243. package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
  244. package/src/plugins/types.ts +17 -0
  245. package/src/prompts/templates/system-sections.ts +2 -2
  246. package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
  247. package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
  248. package/src/providers/call-site-routing.ts +77 -14
  249. package/src/providers/connection-resolution.ts +42 -2
  250. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
  251. package/src/providers/inference/adapter-factory.ts +70 -12
  252. package/src/providers/model-catalog.ts +0 -11
  253. package/src/providers/model-intents.ts +11 -0
  254. package/src/providers/registry.ts +8 -0
  255. package/src/providers/retry.ts +85 -17
  256. package/src/providers/types.ts +8 -1
  257. package/src/runtime/__tests__/agent-wake.test.ts +181 -0
  258. package/src/runtime/agent-wake.ts +132 -9
  259. package/src/runtime/channel-approval-types.ts +25 -0
  260. package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
  261. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
  262. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
  263. package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
  264. package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
  265. package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
  266. package/src/runtime/routes/consolidation-routes.ts +1 -1
  267. package/src/runtime/routes/conversation-management-routes.ts +18 -6
  268. package/src/runtime/routes/conversation-query-routes.ts +14 -23
  269. package/src/runtime/routes/conversation-routes.ts +113 -8
  270. package/src/runtime/routes/credential-routes.ts +1 -5
  271. package/src/runtime/routes/documents-routes.ts +207 -12
  272. package/src/runtime/routes/identity-routes.ts +3 -93
  273. package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
  274. package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
  275. package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
  276. package/src/runtime/routes/notification-routes.ts +4 -1
  277. package/src/runtime/routes/platform-routes.ts +238 -3
  278. package/src/runtime/routes/playground/guard.ts +1 -2
  279. package/src/runtime/routes/slack-channel-routes.ts +2 -4
  280. package/src/runtime/routes/workspace-commit-routes.ts +49 -2
  281. package/src/runtime/routes/workspace-routes.ts +11 -12
  282. package/src/runtime/routes/workspace-utils.ts +49 -2
  283. package/src/runtime/services/conversation-serializer.ts +2 -4
  284. package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
  285. package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
  286. package/src/subagent/__tests__/consult-context.test.ts +84 -0
  287. package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
  288. package/src/subagent/consult-context.ts +410 -0
  289. package/src/subagent/consult-prompt.ts +38 -5
  290. package/src/subagent/manager.ts +9 -4
  291. package/src/subagent/types.ts +8 -0
  292. package/src/telemetry/telemetry-event-sources.ts +7 -0
  293. package/src/telemetry/telemetry-wire-source.json +1 -1
  294. package/src/telemetry/telemetry-wire.generated.ts +1 -0
  295. package/src/telemetry/turn-events-store.ts +30 -1
  296. package/src/telemetry/types.ts +24 -0
  297. package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
  298. package/src/tools/browser/pinned-tabs.ts +3 -1
  299. package/src/tools/subagent/spawn.ts +23 -1
  300. package/src/tools/terminal/safe-env.ts +1 -0
  301. package/src/tools/ui-surface/definitions.ts +39 -1
  302. package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
  303. package/src/tools/ui-surface/visual-validation.ts +787 -0
  304. package/src/tools/workflows/run-workflow.test.ts +1 -0
  305. package/src/util/__tests__/short-title.test.ts +229 -0
  306. package/src/util/__tests__/worker-compute.test.ts +65 -0
  307. package/src/util/cgroup-cpu.ts +93 -0
  308. package/src/util/errors.ts +27 -0
  309. package/src/util/process-tree.ts +19 -0
  310. package/src/util/short-title.ts +189 -0
  311. package/src/util/worker-compute.ts +83 -0
  312. package/src/workspace/byok-default-profile-ensure.ts +25 -15
  313. package/src/workspace/git-service.ts +79 -30
  314. package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
  315. package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
  316. package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
  317. package/src/workspace/migrations/registry.ts +6 -0
  318. package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
@@ -21,7 +21,10 @@ import {
21
21
  parseAccessRequestPayload,
22
22
  } from "./access-request-copy.js";
23
23
  import { isGuardianSensitiveEvent } from "./adapters/macos.js";
24
- import { pairDeliveryWithConversation } from "./conversation-pairing.js";
24
+ import {
25
+ pairDeliveryWithConversation,
26
+ type PairingResult,
27
+ } from "./conversation-pairing.js";
25
28
  import { composeFallbackCopy } from "./copy-composer.js";
26
29
  import {
27
30
  createDelivery,
@@ -42,8 +45,11 @@ import { nonEmpty } from "./notification-utils.js";
42
45
  import type { NotificationSignal } from "./signal.js";
43
46
  import type {
44
47
  ChannelAdapter,
48
+ ChannelDeliveryObserver,
45
49
  ChannelDeliveryPayload,
50
+ ChannelDestination,
46
51
  ConversationAction,
52
+ DeliveryResult,
47
53
  NotificationChannel,
48
54
  NotificationDecision,
49
55
  NotificationDeliveryResult,
@@ -221,6 +227,50 @@ export type OnConversationCreatedFn = (
221
227
  ) => void | Promise<void>;
222
228
  export interface BroadcastDecisionOptions {
223
229
  onConversationCreated?: OnConversationCreatedFn;
230
+ /** Deadline override for tests; defaults to PLATFORM_OUTCOME_DEADLINE_MS. */
231
+ platformOutcomeDeadlineMs?: number;
232
+ }
233
+
234
+ // Cap on how long the deferred vellum send waits for the platform dispatch
235
+ // outcome: a duplicate banner beats a late one.
236
+ const PLATFORM_OUTCOME_DEADLINE_MS = 2_500;
237
+
238
+ /**
239
+ * A channel dispatch that is fully prepared (destination resolved, copy
240
+ * rendered, conversation paired, pending delivery row created) but whose
241
+ * adapter send has not yet run. Used to defer the vellum send until the
242
+ * platform dispatch outcome is known.
243
+ */
244
+ interface PendingChannelDispatch {
245
+ adapter: ChannelAdapter;
246
+ channel: NotificationChannel;
247
+ destination: ChannelDestination;
248
+ payload: ChannelDeliveryPayload;
249
+ deliveryId: string;
250
+ destinationLabel: string;
251
+ pairing: PairingResult;
252
+ hasPersistedDecision: boolean;
253
+ }
254
+
255
+ /**
256
+ * Build a delivery result for a prepared dispatch. The single construction
257
+ * site for every post-preparation outcome branch, so a future field cannot
258
+ * be added to one branch and missed in another.
259
+ */
260
+ function buildDeliveryResult(
261
+ dispatch: PendingChannelDispatch,
262
+ status: NotificationDeliveryResult["status"],
263
+ overrides?: Partial<NotificationDeliveryResult>,
264
+ ): NotificationDeliveryResult {
265
+ return {
266
+ channel: dispatch.channel,
267
+ destination: dispatch.destinationLabel,
268
+ status,
269
+ conversationId: dispatch.pairing.conversationId ?? undefined,
270
+ messageId: dispatch.pairing.messageId ?? undefined,
271
+ conversationStrategy: dispatch.pairing.strategy,
272
+ ...overrides,
273
+ };
224
274
  }
225
275
 
226
276
  export class NotificationBroadcaster {
@@ -268,16 +318,22 @@ export class NotificationBroadcaster {
268
318
 
269
319
  // Ensure vellum is processed first so the notification_conversation_created
270
320
  // event fires immediately, before slower channel sends (e.g. Telegram 30s
271
- // timeout) can delay it past the macOS deep-link retry window.
272
- const orderedChannels = [...decision.selectedChannels].sort((a, b) => {
273
- if (a === "vellum") {
274
- return -1;
321
+ // timeout) can delay it past the macOS deep-link retry window. Platform
322
+ // sorts second: the vellum intent carries remotePushDispatched, so its
323
+ // deferred send (below) flushes right after the platform outcome is known
324
+ // instead of waiting behind slower channels.
325
+ const dispatchRank = (channel: NotificationChannel): number => {
326
+ if (channel === "vellum") {
327
+ return 0;
275
328
  }
276
- if (b === "vellum") {
329
+ if (channel === "platform") {
277
330
  return 1;
278
331
  }
279
- return 0;
280
- });
332
+ return 2;
333
+ };
334
+ const orderedChannels = [...decision.selectedChannels].sort(
335
+ (a, b) => dispatchRank(a) - dispatchRank(b),
336
+ );
281
337
 
282
338
  // Pre-compute fallback copy in case any channel is missing rendered copy
283
339
  let fallbackCopy: Partial<
@@ -301,369 +357,519 @@ export class NotificationBroadcaster {
301
357
  messageId: string | null;
302
358
  } | null = null;
303
359
 
304
- for (const channel of orderedChannels) {
305
- const adapter = this.adapters.get(channel);
306
- if (!adapter) {
307
- log.warn(
308
- { channel, signalId: signal.signalId },
309
- "No adapter registered for channel -- skipping",
310
- );
311
- results.push({
312
- channel,
313
- destination: "",
314
- status: "skipped",
315
- errorMessage: `No adapter for channel: ${channel}`,
316
- });
317
- continue;
360
+ // The vellum intent's remotePushDispatched flag must reflect the ACTUAL
361
+ // platform dispatch outcome, not channel selection: when both channels
362
+ // are selected, the vellum channel is still prepared first (pairing, the
363
+ // conversation-created emission, and the pending delivery row all run
364
+ // eagerly, so the platform deep link keeps the vellum pairing carry), but
365
+ // its adapter send is deferred until the platform adapter reports whether
366
+ // the platform accepted a device push. Typical cost: one fast HTTP
367
+ // round-trip before the local banner goes out, capped at
368
+ // PLATFORM_OUTCOME_DEADLINE_MS.
369
+ let deferredVellumSend: PendingChannelDispatch | null = null;
370
+ let platformRemotePushAccepted = false;
371
+ let platformRemotePushPlatforms: ("ios" | "android")[] | undefined;
372
+ const flushDeferredVellumSend = async (): Promise<void> => {
373
+ if (!deferredVellumSend) {
374
+ return;
318
375
  }
376
+ const pending = deferredVellumSend;
377
+ deferredVellumSend = null;
378
+ pending.payload.remotePushDispatched = platformRemotePushAccepted;
379
+ pending.payload.remotePushPlatforms = platformRemotePushPlatforms;
380
+ await this.sendAndRecord(pending, signal, results);
381
+ };
319
382
 
320
- const destination = destinations.get(channel);
321
- if (!destination) {
322
- log.warn(
323
- { channel, signalId: signal.signalId },
324
- "Could not resolve destination -- skipping",
325
- );
326
- results.push({
327
- channel,
328
- destination: "",
329
- status: "skipped",
330
- errorMessage: `Destination not resolved for channel: ${channel}`,
331
- });
332
- continue;
333
- }
383
+ try {
384
+ for (const channel of orderedChannels) {
385
+ // Platform sorts immediately after vellum, so reaching any later
386
+ // channel means the platform outcome is settled (or the platform
387
+ // channel was skipped): flush the deferred vellum send now so the
388
+ // local banner is not held behind slower sends. On the vellum
389
+ // iteration itself this is a no-op -- vellum appears at most once in
390
+ // the decision's channels and sorts first, so nothing is deferred yet.
391
+ if (channel !== "platform") {
392
+ await flushDeferredVellumSend();
393
+ }
334
394
 
335
- // Pull rendered copy from the decision; fall back to copy-composer if
336
- // missing or effectively blank. The decision engine's LLM occasionally
337
- // returns empty title/body strings that pass type-only validation, so
338
- // treat copy with no usable content the same as missing copy.
339
- let copy = decision.renderedCopy[channel];
340
- if (!copy || (!copy.title?.trim() && !copy.body?.trim())) {
341
- if (copy) {
395
+ const adapter = this.adapters.get(channel);
396
+ if (!adapter) {
342
397
  log.warn(
343
398
  { channel, signalId: signal.signalId },
344
- "Decision copy has empty title and body — using fallback",
399
+ "No adapter registered for channel -- skipping",
345
400
  );
401
+ results.push({
402
+ channel,
403
+ destination: "",
404
+ status: "skipped",
405
+ errorMessage: `No adapter for channel: ${channel}`,
406
+ });
407
+ continue;
346
408
  }
347
- if (!fallbackCopy) {
348
- fallbackCopy = composeFallbackCopy(signal, decision.selectedChannels);
349
- }
350
- copy = fallbackCopy[channel];
351
- }
352
-
353
- // Fail closed: if neither the decision nor the fallback composer produced
354
- // a usable body, skip the channel rather than leaking the raw event name
355
- // as placeholder text. The pre-send `checkRenderedCopyQuality` only sees
356
- // `decision.renderedCopy`, so this is the last guard before delivery.
357
- if (!copy || !copy.body?.trim()) {
358
- log.warn(
359
- { channel, signalId: signal.signalId },
360
- "No usable rendered copy available -- skipping channel to avoid leaking event name",
361
- );
362
- results.push({
363
- channel,
364
- destination: destination.endpoint ?? channel,
365
- status: "skipped",
366
- errorMessage: `No usable rendered copy for channel: ${channel}`,
367
- });
368
- continue;
369
- }
370
409
 
371
- // For tool_grant_request signals, prefer the deterministic template seed
372
- // over LLM-generated prose. The enriched questionText is already concise
373
- // and informative — LLM rewording just adds noise.
374
- if (signal.contextPayload?.requestKind === "tool_grant_request") {
375
- if (!fallbackCopy) {
376
- fallbackCopy = composeFallbackCopy(signal, decision.selectedChannels);
377
- }
378
- const templateSeed = fallbackCopy[channel]?.conversationSeedMessage;
379
- if (templateSeed) {
380
- copy = { ...copy, conversationSeedMessage: templateSeed };
410
+ const destination = destinations.get(channel);
411
+ if (!destination) {
412
+ log.warn(
413
+ { channel, signalId: signal.signalId },
414
+ "Could not resolve destination -- skipping",
415
+ );
416
+ results.push({
417
+ channel,
418
+ destination: "",
419
+ status: "skipped",
420
+ errorMessage: `Destination not resolved for channel: ${channel}`,
421
+ });
422
+ continue;
381
423
  }
382
- }
383
-
384
- // Resolve the per-channel conversation action from the decision (default: start_new)
385
- const conversationAction: ConversationAction | undefined =
386
- decision.conversationActions?.[channel];
387
424
 
388
- // Check for duplicate delivery BEFORE pairing to avoid side effects
389
- // (e.g. appending seed messages to existing conversations) on retry paths
390
- // where a delivery row already exists.
391
- const persistedDecisionId = decision.persistedDecisionId;
392
- const hasPersistedDecision = typeof persistedDecisionId === "string";
393
- if (hasPersistedDecision) {
394
- const existingDelivery = findDeliveryByDecisionAndChannel(
395
- persistedDecisionId,
396
- channel,
397
- );
398
- if (existingDelivery) {
399
- // On retry paths the vellum row already exists, so the fresh-pairing
400
- // carry below never runs — seed the platform deep-link carry from
401
- // the duplicate row's conversation instead.
402
- if (channel === "vellum" && existingDelivery.conversationId) {
403
- vellumPairing = {
404
- conversationId: existingDelivery.conversationId,
405
- messageId: existingDelivery.messageId,
406
- };
425
+ // Pull rendered copy from the decision; fall back to copy-composer if
426
+ // missing or effectively blank. The decision engine's LLM occasionally
427
+ // returns empty title/body strings that pass type-only validation, so
428
+ // treat copy with no usable content the same as missing copy.
429
+ let copy = decision.renderedCopy[channel];
430
+ if (!copy || (!copy.title?.trim() && !copy.body?.trim())) {
431
+ if (copy) {
432
+ log.warn(
433
+ { channel, signalId: signal.signalId },
434
+ "Decision copy has empty title and body -- using fallback",
435
+ );
407
436
  }
408
- log.info(
409
- {
410
- channel,
411
- signalId: signal.signalId,
412
- existingDeliveryId: existingDelivery.id,
413
- },
414
- "Delivery already exists for this decision+channel skipping duplicate",
437
+ // A policy-forced platform channel has no rendered copy of its own
438
+ // (the decision engine renders only the channels it selected). The
439
+ // push mirrors the in-app banner -- and the iOS dedup suppresses the
440
+ // vellum banner in its favor -- so reuse the vellum channel's
441
+ // rendered copy before falling back to deterministic templates.
442
+ const vellumCopy =
443
+ channel === "platform" ? decision.renderedCopy.vellum : undefined;
444
+ if (vellumCopy?.body?.trim()) {
445
+ copy = vellumCopy;
446
+ } else {
447
+ if (!fallbackCopy) {
448
+ fallbackCopy = composeFallbackCopy(
449
+ signal,
450
+ decision.selectedChannels,
451
+ );
452
+ }
453
+ copy = fallbackCopy[channel];
454
+ }
455
+ }
456
+
457
+ // Fail closed: if neither the decision nor the fallback composer produced
458
+ // a usable body, skip the channel rather than leaking the raw event name
459
+ // as placeholder text. The pre-send `checkRenderedCopyQuality` only sees
460
+ // `decision.renderedCopy`, so this is the last guard before delivery.
461
+ if (!copy || !copy.body?.trim()) {
462
+ log.warn(
463
+ { channel, signalId: signal.signalId },
464
+ "No usable rendered copy available -- skipping channel to avoid leaking event name",
415
465
  );
416
466
  results.push({
417
467
  channel,
418
468
  destination: destination.endpoint ?? channel,
419
469
  status: "skipped",
420
- errorMessage: "Duplicate delivery skipped",
421
- conversationId: existingDelivery.conversationId ?? undefined,
422
- messageId: existingDelivery.messageId ?? undefined,
423
- conversationStrategy:
424
- existingDelivery.conversationStrategy ?? undefined,
470
+ errorMessage: `No usable rendered copy for channel: ${channel}`,
425
471
  });
426
472
  continue;
427
473
  }
428
- }
429
474
 
430
- // Pair the delivery with a conversation before sending, passing the conversation action
431
- // and destination binding context for channel-scoped continuation
432
- const pairing = await pairDeliveryWithConversation(
433
- signal,
434
- channel,
435
- copy,
436
- { conversationAction, bindingContext: destination.bindingContext },
437
- );
475
+ // For tool_grant_request signals, prefer the deterministic template seed
476
+ // over LLM-generated prose. The enriched questionText is already concise
477
+ // and informative -- LLM rewording just adds noise.
478
+ if (signal.contextPayload?.requestKind === "tool_grant_request") {
479
+ if (!fallbackCopy) {
480
+ fallbackCopy = composeFallbackCopy(
481
+ signal,
482
+ decision.selectedChannels,
483
+ );
484
+ }
485
+ const templateSeed = fallbackCopy[channel]?.conversationSeedMessage;
486
+ if (templateSeed) {
487
+ copy = { ...copy, conversationSeedMessage: templateSeed };
488
+ }
489
+ }
438
490
 
439
- if (channel === "vellum" && pairing.conversationId) {
440
- vellumPairing = {
441
- conversationId: pairing.conversationId,
442
- messageId: pairing.messageId,
443
- };
444
- }
491
+ // Resolve the per-channel conversation action from the decision (default: start_new)
492
+ const conversationAction: ConversationAction | undefined =
493
+ decision.conversationActions?.[channel];
494
+
495
+ // Check for duplicate delivery BEFORE pairing to avoid side effects
496
+ // (e.g. appending seed messages to existing conversations) on retry paths
497
+ // where a delivery row already exists.
498
+ const persistedDecisionId = decision.persistedDecisionId;
499
+ const hasPersistedDecision = typeof persistedDecisionId === "string";
500
+ if (hasPersistedDecision) {
501
+ const existingDelivery = findDeliveryByDecisionAndChannel(
502
+ persistedDecisionId,
503
+ channel,
504
+ );
505
+ if (existingDelivery) {
506
+ // On retry paths the vellum row already exists, so the fresh-pairing
507
+ // carry below never runs -- seed the platform deep-link carry from
508
+ // the duplicate row's conversation instead.
509
+ if (channel === "vellum" && existingDelivery.conversationId) {
510
+ vellumPairing = {
511
+ conversationId: existingDelivery.conversationId,
512
+ messageId: existingDelivery.messageId,
513
+ };
514
+ }
515
+ log.info(
516
+ {
517
+ channel,
518
+ signalId: signal.signalId,
519
+ existingDeliveryId: existingDelivery.id,
520
+ },
521
+ "Delivery already exists for this decision+channel -- skipping duplicate",
522
+ );
523
+ results.push({
524
+ channel,
525
+ destination: destination.endpoint ?? channel,
526
+ status: "skipped",
527
+ errorMessage: "Duplicate delivery skipped",
528
+ conversationId: existingDelivery.conversationId ?? undefined,
529
+ messageId: existingDelivery.messageId ?? undefined,
530
+ conversationStrategy:
531
+ existingDelivery.conversationStrategy ?? undefined,
532
+ });
533
+ continue;
534
+ }
535
+ }
445
536
 
446
- // For the vellum and platform channels, merge the conversationId into
447
- // deep-link metadata so notification taps can navigate to the
448
- // conversation. Prefer the channel's own pairing; platform (push_only,
449
- // pairs nothing) takes this broadcast's vellum pairing; otherwise fall
450
- // back to sourceContextId when it resolves to a real row. Sentinel
451
- // context ids (job IDs, call session IDs, access-req-* strings) leave
452
- // the deep link without a conversation, and the client opens the app to
453
- // its default landing.
454
- let deepLinkTarget = decision.deepLinkTarget;
455
- if (channel === "vellum" || channel === "platform") {
456
- const deepLinkPairing =
457
- channel === "platform" && !pairing.conversationId
458
- ? (vellumPairing ?? pairing)
459
- : pairing;
460
- const deepLinkConversationId =
461
- deepLinkPairing.conversationId ??
462
- resolveSourceConversationId(signal.sourceContextId) ??
463
- resolveDeepLinkConversationId(signal.contextPayload);
464
- if (deepLinkConversationId) {
465
- deepLinkTarget = {
466
- ...deepLinkTarget,
467
- conversationId: deepLinkConversationId,
537
+ // Pair the delivery with a conversation before sending, passing the conversation action
538
+ // and destination binding context for channel-scoped continuation
539
+ const pairing = await pairDeliveryWithConversation(
540
+ signal,
541
+ channel,
542
+ copy,
543
+ { conversationAction, bindingContext: destination.bindingContext },
544
+ );
545
+
546
+ if (channel === "vellum" && pairing.conversationId) {
547
+ vellumPairing = {
548
+ conversationId: pairing.conversationId,
549
+ messageId: pairing.messageId,
468
550
  };
469
- if (deepLinkPairing.messageId) {
551
+ }
552
+
553
+ // For the vellum and platform channels, merge the conversationId into
554
+ // deep-link metadata so notification taps can navigate to the
555
+ // conversation. Prefer the channel's own pairing; platform (push_only,
556
+ // pairs nothing) takes this broadcast's vellum pairing; otherwise fall
557
+ // back to sourceContextId when it resolves to a real row. Sentinel
558
+ // context ids (job IDs, call session IDs, access-req-* strings) leave
559
+ // the deep link without a conversation, and the client opens the app to
560
+ // its default landing.
561
+ let deepLinkTarget = decision.deepLinkTarget;
562
+ if (channel === "vellum" || channel === "platform") {
563
+ const deepLinkPairing =
564
+ channel === "platform" && !pairing.conversationId
565
+ ? (vellumPairing ?? pairing)
566
+ : pairing;
567
+ const deepLinkConversationId =
568
+ deepLinkPairing.conversationId ??
569
+ resolveSourceConversationId(signal.sourceContextId) ??
570
+ resolveDeepLinkConversationId(signal.contextPayload);
571
+ if (deepLinkConversationId) {
470
572
  deepLinkTarget = {
471
573
  ...deepLinkTarget,
472
- messageId: deepLinkPairing.messageId,
574
+ conversationId: deepLinkConversationId,
473
575
  };
576
+ if (deepLinkPairing.messageId) {
577
+ deepLinkTarget = {
578
+ ...deepLinkTarget,
579
+ messageId: deepLinkPairing.messageId,
580
+ };
581
+ }
474
582
  }
475
583
  }
476
- }
477
-
478
- if (channel === "vellum" && pairing.conversationId) {
479
- // Resolve guardian scoping for conversation-created events so clients
480
- // can filter guardian-sensitive conversations the same way they filter
481
- // guardian-sensitive notification intents.
482
- const guardianPrincipalId =
483
- typeof destination.metadata?.guardianPrincipalId === "string"
484
- ? destination.metadata.guardianPrincipalId
485
- : undefined;
486
- const targetGuardianPrincipalId =
487
- guardianPrincipalId &&
488
- isGuardianSensitiveEvent(signal.sourceEventName)
489
- ? guardianPrincipalId
490
- : undefined;
491
-
492
- const conversationTitle =
493
- copy.conversationTitle ?? copy.title ?? signal.sourceEventName;
494
- const conversationSilent =
495
- signal.attentionHints.urgency !== "high" &&
496
- signal.attentionHints.urgency !== "critical";
497
- const info: ConversationCreatedInfo = {
498
- conversationId: pairing.conversationId,
499
- title: conversationTitle,
500
- sourceEventName: signal.sourceEventName,
501
- targetGuardianPrincipalId,
502
- groupId: signal.conversationMetadata?.groupId,
503
- source: signal.conversationMetadata?.source,
504
- silent: conversationSilent,
505
- };
506
584
 
507
- // The per-dispatch onConversationCreated callback fires whenever a vellum
508
- // conversation is paired (new or reused) because callers like
509
- // dispatchGuardianQuestion rely on it to create delivery bookkeeping
510
- // rows before emitNotificationSignal() returns. A returned promise is
511
- // awaited so those rows are durable before the client can learn of
512
- // the conversation and act on its approval card.
513
- if (options?.onConversationCreated) {
514
- try {
515
- await options.onConversationCreated(info);
516
- } catch (err) {
517
- log.error(
518
- { err, signalId: signal.signalId },
519
- "per-dispatch onConversationCreated callback failed — continuing broadcast",
520
- );
521
- }
522
- }
585
+ if (channel === "vellum" && pairing.conversationId) {
586
+ // Resolve guardian scoping for conversation-created events so clients
587
+ // can filter guardian-sensitive conversations the same way they filter
588
+ // guardian-sensitive notification intents.
589
+ const guardianPrincipalId =
590
+ typeof destination.metadata?.guardianPrincipalId === "string"
591
+ ? destination.metadata.guardianPrincipalId
592
+ : undefined;
593
+ const targetGuardianPrincipalId =
594
+ guardianPrincipalId &&
595
+ isGuardianSensitiveEvent(signal.sourceEventName)
596
+ ? guardianPrincipalId
597
+ : undefined;
598
+
599
+ const conversationTitle =
600
+ copy.conversationTitle ?? copy.title ?? signal.sourceEventName;
601
+ const conversationSilent =
602
+ signal.attentionHints.urgency !== "high" &&
603
+ signal.attentionHints.urgency !== "critical";
604
+ const info: ConversationCreatedInfo = {
605
+ conversationId: pairing.conversationId,
606
+ title: conversationTitle,
607
+ sourceEventName: signal.sourceEventName,
608
+ targetGuardianPrincipalId,
609
+ groupId: signal.conversationMetadata?.groupId,
610
+ source: signal.conversationMetadata?.source,
611
+ silent: conversationSilent,
612
+ };
523
613
 
524
- // Emit notification_conversation_created event only when a NEW
525
- // conversation was actually created. Reusing an existing conversation
526
- // should not fire the event the client already knows about the
527
- // conversation.
528
- if (
529
- pairing.createdNewConversation &&
530
- pairing.strategy === "start_new_conversation"
531
- ) {
532
- if (this.onConversationCreated) {
614
+ // The per-dispatch onConversationCreated callback fires whenever a vellum
615
+ // conversation is paired (new or reused) because callers like
616
+ // dispatchGuardianQuestion rely on it to create delivery bookkeeping
617
+ // rows before emitNotificationSignal() returns. A returned promise is
618
+ // awaited so those rows are durable before the client can learn of
619
+ // the conversation and act on its approval card.
620
+ if (options?.onConversationCreated) {
533
621
  try {
534
- await this.onConversationCreated(info);
622
+ await options.onConversationCreated(info);
535
623
  } catch (err) {
536
624
  log.error(
537
625
  { err, signalId: signal.signalId },
538
- "onConversationCreated callback failed continuing broadcast",
626
+ "per-dispatch onConversationCreated callback failed -- continuing broadcast",
539
627
  );
540
628
  }
541
629
  }
630
+
631
+ // Emit notification_conversation_created event only when a NEW
632
+ // conversation was actually created. Reusing an existing conversation
633
+ // should not fire the event -- the client already knows about the
634
+ // conversation.
635
+ if (
636
+ pairing.createdNewConversation &&
637
+ pairing.strategy === "start_new_conversation"
638
+ ) {
639
+ if (this.onConversationCreated) {
640
+ try {
641
+ await this.onConversationCreated(info);
642
+ } catch (err) {
643
+ log.error(
644
+ { err, signalId: signal.signalId },
645
+ "onConversationCreated callback failed -- continuing broadcast",
646
+ );
647
+ }
648
+ }
649
+ }
542
650
  }
543
- }
544
651
 
545
- const deliveryId = uuid();
546
- const destinationLabel = destination.endpoint ?? channel;
547
-
548
- const payload: ChannelDeliveryPayload = {
549
- deliveryId,
550
- sourceEventName: signal.sourceEventName,
551
- copy,
552
- deepLinkTarget,
553
- contextPayload: signal.contextPayload,
554
- urgency: signal.attentionHints.urgency,
555
- approvalContext,
556
- accessRequestContext,
557
- toolApprovalSource,
558
- };
559
-
560
- // Compute conversation decision audit fields for the delivery record
561
- const conversationAudit = {
562
- conversationAction: conversationAction?.action ?? "start_new",
563
- conversationTargetId:
564
- conversationAction?.action === "reuse_existing"
565
- ? conversationAction.conversationId
566
- : undefined,
567
- conversationFallbackUsed: pairing.conversationFallbackUsed,
568
- };
569
-
570
- try {
571
- if (hasPersistedDecision) {
572
- createDelivery({
573
- id: deliveryId,
574
- notificationDecisionId: persistedDecisionId,
575
- channel,
576
- destination: destinationLabel,
577
- status: "pending",
578
- attempt: 1,
579
- renderedTitle: copy.title,
580
- renderedBody: copy.body,
581
- conversationId: pairing.conversationId ?? undefined,
582
- messageId: pairing.messageId ?? undefined,
583
- conversationStrategy: pairing.strategy,
584
- ...conversationAudit,
585
- });
586
- } else {
587
- log.warn(
588
- { channel, signalId: signal.signalId },
589
- "No persisted decision ID -- skipping delivery record creation",
652
+ const deliveryId = uuid();
653
+ const destinationLabel = destination.endpoint ?? channel;
654
+
655
+ const payload: ChannelDeliveryPayload = {
656
+ deliveryId,
657
+ correlationId: signal.signalId,
658
+ sourceEventName: signal.sourceEventName,
659
+ copy,
660
+ deepLinkTarget,
661
+ contextPayload: signal.contextPayload,
662
+ urgency: signal.attentionHints.urgency,
663
+ approvalContext,
664
+ accessRequestContext,
665
+ toolApprovalSource,
666
+ };
667
+
668
+ const dispatch: PendingChannelDispatch = {
669
+ adapter,
670
+ channel,
671
+ destination,
672
+ payload,
673
+ deliveryId,
674
+ destinationLabel,
675
+ pairing,
676
+ hasPersistedDecision,
677
+ };
678
+
679
+ // Compute conversation decision audit fields for the delivery record
680
+ const conversationAudit = {
681
+ conversationAction: conversationAction?.action ?? "start_new",
682
+ conversationTargetId:
683
+ conversationAction?.action === "reuse_existing"
684
+ ? conversationAction.conversationId
685
+ : undefined,
686
+ conversationFallbackUsed: pairing.conversationFallbackUsed,
687
+ };
688
+
689
+ try {
690
+ if (hasPersistedDecision) {
691
+ createDelivery({
692
+ id: deliveryId,
693
+ notificationDecisionId: persistedDecisionId,
694
+ channel,
695
+ destination: destinationLabel,
696
+ status: "pending",
697
+ attempt: 1,
698
+ renderedTitle: copy.title,
699
+ renderedBody: copy.body,
700
+ conversationId: pairing.conversationId ?? undefined,
701
+ messageId: pairing.messageId ?? undefined,
702
+ conversationStrategy: pairing.strategy,
703
+ ...conversationAudit,
704
+ });
705
+ } else {
706
+ log.warn(
707
+ { channel, signalId: signal.signalId },
708
+ "No persisted decision ID -- skipping delivery record creation",
709
+ );
710
+ }
711
+ } catch (err) {
712
+ const errorMessage = err instanceof Error ? err.message : String(err);
713
+ log.error(
714
+ { err, channel, signalId: signal.signalId },
715
+ "Failed to create delivery record",
716
+ );
717
+ results.push(
718
+ buildDeliveryResult(dispatch, "failed", { errorMessage }),
590
719
  );
720
+ continue;
591
721
  }
592
722
 
593
- const adapterResult = await adapter.send(payload, destination);
723
+ if (channel === "vellum" && orderedChannels.includes("platform")) {
724
+ // The vellum intent carries remotePushDispatched, so its send waits
725
+ // for the platform outcome; everything else (pairing, events, the
726
+ // pending delivery row) already ran above.
727
+ deferredVellumSend = dispatch;
728
+ continue;
729
+ }
594
730
 
595
- if (adapterResult.success) {
596
- // Prefer the channel-native id the adapter just captured (e.g.
597
- // Slack `ts`) so later edits can target the same message; fall
598
- // back to the pairing-supplied id for channels that surface it
599
- // through conversation pairing instead.
600
- const resolvedMessageId =
601
- adapterResult.messageId ?? pairing.messageId ?? undefined;
602
- if (hasPersistedDecision) {
603
- updateDeliveryStatus(
604
- deliveryId,
605
- "sent",
606
- undefined,
607
- adapterResult.messageId
608
- ? { messageId: adapterResult.messageId }
609
- : undefined,
731
+ if (channel === "platform" && deferredVellumSend) {
732
+ // The local banner is urgent: wait for the platform outcome only up
733
+ // to the deadline (a duplicate banner beats a late one). On expiry
734
+ // the vellum send flushes with remotePushDispatched=false while the
735
+ // dispatch keeps running, recording its delivery row when it
736
+ // settles; the late outcome cannot re-flush vellum.
737
+ const backgroundResults: NotificationDeliveryResult[] = [];
738
+ const platformSend = this.sendAndRecord(
739
+ dispatch,
740
+ signal,
741
+ backgroundResults,
742
+ {
743
+ onRemotePushPlatforms: (platforms) => {
744
+ platformRemotePushPlatforms = platforms;
745
+ },
746
+ },
747
+ ).then((adapterResult) => {
748
+ platformRemotePushAccepted =
749
+ adapterResult?.remotePushAccepted === true;
750
+ platformRemotePushPlatforms = adapterResult?.remotePushPlatforms;
751
+ });
752
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
753
+ const deadline = new Promise<"deadline">((resolve) => {
754
+ deadlineTimer = setTimeout(
755
+ () => resolve("deadline"),
756
+ options?.platformOutcomeDeadlineMs ??
757
+ PLATFORM_OUTCOME_DEADLINE_MS,
610
758
  );
759
+ });
760
+ const raced = await Promise.race([
761
+ platformSend.then(() => "settled" as const),
762
+ deadline,
763
+ ]);
764
+ clearTimeout(deadlineTimer);
765
+ if (raced === "deadline") {
766
+ log.warn(
767
+ { channel, signalId: signal.signalId },
768
+ "Platform dispatch outcome unknown at deadline -- sending the local banner without it",
769
+ );
770
+ // The in-flight dispatch's result lands in backgroundResults,
771
+ // which this call no longer reads; its delivery row still gets
772
+ // the real terminal status.
773
+ results.push(buildDeliveryResult(dispatch, "pending"));
774
+ } else {
775
+ results.push(...backgroundResults);
611
776
  }
612
- results.push({
613
- channel,
614
- destination: destinationLabel,
615
- status: "sent",
777
+ await flushDeferredVellumSend();
778
+ continue;
779
+ }
780
+
781
+ await this.sendAndRecord(dispatch, signal, results);
782
+ }
783
+ } finally {
784
+ // Guarantees the vellum intent is emitted (and its pending delivery
785
+ // row resolved) even when a later channel's prep throws; also covers
786
+ // the common [vellum, platform] selection where no later channel
787
+ // triggered the mid-loop flush.
788
+ await flushDeferredVellumSend();
789
+ }
790
+
791
+ return results;
792
+ }
793
+
794
+ /**
795
+ * Dispatch a prepared payload through its channel adapter and record the
796
+ * outcome (delivery row status + results entry). Returns the adapter's
797
+ * result, or null when the send threw.
798
+ */
799
+ private async sendAndRecord(
800
+ dispatch: PendingChannelDispatch,
801
+ signal: NotificationSignal,
802
+ results: NotificationDeliveryResult[],
803
+ observer?: ChannelDeliveryObserver,
804
+ ): Promise<DeliveryResult | null> {
805
+ const {
806
+ adapter,
807
+ channel,
808
+ destination,
809
+ payload,
810
+ deliveryId,
811
+ pairing,
812
+ hasPersistedDecision,
813
+ } = dispatch;
814
+ try {
815
+ const adapterResult = await adapter.send(payload, destination, observer);
816
+
817
+ if (adapterResult.success) {
818
+ // Prefer the channel-native id the adapter just captured (e.g.
819
+ // Slack `ts`) so later edits can target the same message; fall
820
+ // back to the pairing-supplied id for channels that surface it
821
+ // through conversation pairing instead.
822
+ const resolvedMessageId =
823
+ adapterResult.messageId ?? pairing.messageId ?? undefined;
824
+ if (hasPersistedDecision) {
825
+ updateDeliveryStatus(
826
+ deliveryId,
827
+ "sent",
828
+ undefined,
829
+ adapterResult.messageId
830
+ ? { messageId: adapterResult.messageId }
831
+ : undefined,
832
+ );
833
+ }
834
+ results.push(
835
+ buildDeliveryResult(dispatch, "sent", {
616
836
  sentAt: Date.now(),
617
- conversationId: pairing.conversationId ?? undefined,
618
837
  messageId: resolvedMessageId,
619
- conversationStrategy: pairing.strategy,
620
- });
621
- } else {
622
- if (hasPersistedDecision) {
623
- updateDeliveryStatus(deliveryId, "failed", {
624
- message: adapterResult.error,
625
- });
626
- }
627
- results.push({
628
- channel,
629
- destination: destinationLabel,
630
- status: "failed",
631
- errorMessage: adapterResult.error,
632
- conversationId: pairing.conversationId ?? undefined,
633
- messageId: pairing.messageId ?? undefined,
634
- conversationStrategy: pairing.strategy,
838
+ }),
839
+ );
840
+ } else {
841
+ if (hasPersistedDecision) {
842
+ updateDeliveryStatus(deliveryId, "failed", {
843
+ message: adapterResult.error,
635
844
  });
636
845
  }
637
- } catch (err) {
638
- const errorMessage = err instanceof Error ? err.message : String(err);
639
- log.error(
640
- { err, channel, signalId: signal.signalId },
641
- "Unexpected error during channel delivery",
846
+ results.push(
847
+ buildDeliveryResult(dispatch, "failed", {
848
+ errorMessage: adapterResult.error,
849
+ }),
642
850
  );
851
+ }
852
+ return adapterResult;
853
+ } catch (err) {
854
+ const errorMessage = err instanceof Error ? err.message : String(err);
855
+ log.error(
856
+ { err, channel, signalId: signal.signalId },
857
+ "Unexpected error during channel delivery",
858
+ );
643
859
 
644
- if (hasPersistedDecision) {
645
- try {
646
- updateDeliveryStatus(deliveryId, "failed", {
647
- message: errorMessage,
648
- });
649
- } catch {
650
- // Swallow -- the delivery record may not exist if createDelivery failed
651
- }
860
+ if (hasPersistedDecision) {
861
+ try {
862
+ updateDeliveryStatus(deliveryId, "failed", {
863
+ message: errorMessage,
864
+ });
865
+ } catch {
866
+ // Swallow -- best-effort failure-status update
652
867
  }
653
-
654
- results.push({
655
- channel,
656
- destination: destinationLabel,
657
- status: "failed",
658
- errorMessage,
659
- conversationId: pairing.conversationId ?? undefined,
660
- messageId: pairing.messageId ?? undefined,
661
- conversationStrategy: pairing.strategy,
662
- });
663
868
  }
664
- }
665
869
 
666
- return results;
870
+ results.push(buildDeliveryResult(dispatch, "failed", { errorMessage }));
871
+ return null;
872
+ }
667
873
  }
668
874
  }
669
875