@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
@@ -1,17 +1,14 @@
1
1
  /**
2
- * Platform push adapter delivers notifications to iOS/web clients via
3
- * the platform's APNs dispatch endpoint.
2
+ * Platform push adapter for native mobile notification delivery.
4
3
  *
5
4
  * POSTs a `notification_intent` payload to
6
5
  * `/v1/assistants/{id}/push/dispatch/`. The platform endpoint fans the
7
- * notification out to all registered device tokens for the bound user and
8
- * gates on the `ios-remote-push-enabled` feature flag server-side (returning 202
9
- * with `{ skipped: "flag_off" }` when the flag is OFF — no action needed
10
- * from the daemon).
6
+ * notification out to registered device tokens for the bound user. Provider
7
+ * feature gates return 202 with `{ skipped: "flag_off" }` when no provider runs.
11
8
  *
12
9
  * Guardian-sensitive notifications (approval requests, access requests)
13
10
  * are annotated with `targetGuardianPrincipalId` so the platform can
14
- * scope APNs fan-out to guardian-bound devices, mirroring the macOS adapter.
11
+ * scope native fan-out to guardian-bound devices, mirroring the macOS adapter.
15
12
  */
16
13
 
17
14
  import { VellumPlatformClient } from "../../platform/client.js";
@@ -23,6 +20,7 @@ import {
23
20
  } from "../../util/retry.js";
24
21
  import type {
25
22
  ChannelAdapter,
23
+ ChannelDeliveryObserver,
26
24
  ChannelDeliveryPayload,
27
25
  ChannelDestination,
28
26
  DeliveryResult,
@@ -35,6 +33,17 @@ const log = getLogger("notif-adapter-platform");
35
33
  // Exponential backoff delays for 5xx/timeout retries: 250ms → 1s → 4s
36
34
  const RETRY_DELAYS_MS = [250, 1_000, 4_000] as const;
37
35
 
36
+ // Per-attempt abort timeout. The underlying platform fetch has no timeout of
37
+ // its own, and the broadcaster defers the urgent local banner on this
38
+ // dispatch's outcome -- a hung platform must fail the attempt, not stall it.
39
+ const ATTEMPT_TIMEOUT_MS = 5_000;
40
+
41
+ /** Whether a fetch error is the per-attempt abort timeout firing. */
42
+ function isAttemptTimeout(err: unknown): boolean {
43
+ const name = (err as { name?: unknown } | null)?.name;
44
+ return name === "TimeoutError" || name === "AbortError";
45
+ }
46
+
38
47
  interface DispatchBody {
39
48
  delivery_id?: string;
40
49
  source_event_name: string;
@@ -45,12 +54,29 @@ interface DispatchBody {
45
54
  target_guardian_principal_id?: string;
46
55
  }
47
56
 
57
+ type RemotePushPlatform = "ios" | "android";
58
+
59
+ function acceptedPlatforms(body: unknown): RemotePushPlatform[] | undefined {
60
+ if (typeof body !== "object" || body === null) {
61
+ return undefined;
62
+ }
63
+ const value = (body as { accepted_platforms?: unknown }).accepted_platforms;
64
+ if (!Array.isArray(value)) {
65
+ return undefined;
66
+ }
67
+ return value.filter(
68
+ (platform): platform is RemotePushPlatform =>
69
+ platform === "ios" || platform === "android",
70
+ );
71
+ }
72
+
48
73
  export class PlatformPushAdapter implements ChannelAdapter {
49
74
  readonly channel: NotificationChannel = "platform";
50
75
 
51
76
  async send(
52
77
  payload: ChannelDeliveryPayload,
53
78
  destination: ChannelDestination,
79
+ observer?: ChannelDeliveryObserver,
54
80
  ): Promise<DeliveryResult> {
55
81
  const client = await VellumPlatformClient.create();
56
82
  if (!client) {
@@ -80,7 +106,7 @@ export class PlatformPushAdapter implements ChannelAdapter {
80
106
  : undefined;
81
107
 
82
108
  const body: DispatchBody = {
83
- delivery_id: payload.deliveryId,
109
+ delivery_id: payload.correlationId ?? payload.deliveryId,
84
110
  source_event_name: payload.sourceEventName,
85
111
  title: payload.copy.title,
86
112
  body: payload.copy.body,
@@ -90,6 +116,10 @@ export class PlatformPushAdapter implements ChannelAdapter {
90
116
  };
91
117
 
92
118
  const path = `/v1/assistants/${encodeURIComponent(client.platformAssistantId)}/push/dispatch/`;
119
+ const accumulatedPlatforms = new Set<RemotePushPlatform>();
120
+ let platformsReported = false;
121
+ const remotePushPlatforms = () =>
122
+ platformsReported ? [...accumulatedPlatforms] : undefined;
93
123
 
94
124
  for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
95
125
  let response: Response;
@@ -98,9 +128,13 @@ export class PlatformPushAdapter implements ChannelAdapter {
98
128
  method: "POST",
99
129
  headers: { "Content-Type": "application/json" },
100
130
  body: JSON.stringify(body),
131
+ signal: AbortSignal.timeout(ATTEMPT_TIMEOUT_MS),
101
132
  });
102
133
  } catch (err) {
103
- if (attempt < RETRY_DELAYS_MS.length && isRetryableNetworkError(err)) {
134
+ if (
135
+ attempt < RETRY_DELAYS_MS.length &&
136
+ (isAttemptTimeout(err) || isRetryableNetworkError(err))
137
+ ) {
104
138
  log.warn(
105
139
  {
106
140
  attempt,
@@ -117,20 +151,62 @@ export class PlatformPushAdapter implements ChannelAdapter {
117
151
  { attempt, sourceEventName: payload.sourceEventName, err },
118
152
  "Failed to dispatch platform push notification",
119
153
  );
120
- return { success: false, error: message };
154
+ return {
155
+ success: false,
156
+ error: message,
157
+ remotePushPlatforms: remotePushPlatforms(),
158
+ };
159
+ }
160
+
161
+ const responseText = await response.text().catch(() => "");
162
+ let responseBody: unknown = null;
163
+ try {
164
+ responseBody = JSON.parse(responseText) as unknown;
165
+ } catch {
166
+ responseBody = null;
167
+ }
168
+ const responsePlatforms = acceptedPlatforms(responseBody);
169
+ if (responsePlatforms) {
170
+ platformsReported = true;
171
+ for (const platform of responsePlatforms) {
172
+ accumulatedPlatforms.add(platform);
173
+ }
174
+ observer?.onRemotePushPlatforms(remotePushPlatforms()!);
121
175
  }
122
176
 
123
177
  if (response.ok) {
178
+ // A 2xx does not mean a push went out: the server returns
179
+ // 202 {"skipped": ...} when the feature flag is off or no tokens
180
+ // are registered, and 200 with tokens_sent: 0 on idempotent
181
+ // replays. Only report acceptance when the body confirms at least
182
+ // one device push was dispatched; an unparseable or ambiguous body
183
+ // counts as not accepted (a duplicate client banner beats a lost
184
+ // notification).
185
+ const parsedBody = responseBody as {
186
+ accepted_platforms?: unknown;
187
+ skipped?: unknown;
188
+ tokens_sent?: unknown;
189
+ } | null;
190
+ const remotePushAccepted =
191
+ parsedBody != null &&
192
+ !parsedBody.skipped &&
193
+ typeof parsedBody.tokens_sent === "number" &&
194
+ parsedBody.tokens_sent > 0;
124
195
  log.info(
125
196
  {
126
197
  sourceEventName: payload.sourceEventName,
127
198
  title: payload.copy.title,
128
199
  guardianScoped: targetGuardianPrincipalId != null,
129
200
  status: response.status,
201
+ remotePushAccepted,
130
202
  },
131
203
  "Platform push dispatched",
132
204
  );
133
- return { success: true };
205
+ return {
206
+ success: true,
207
+ remotePushAccepted,
208
+ remotePushPlatforms: remotePushPlatforms(),
209
+ };
134
210
  }
135
211
 
136
212
  if (
@@ -149,18 +225,18 @@ export class PlatformPushAdapter implements ChannelAdapter {
149
225
  continue;
150
226
  }
151
227
 
152
- const errorText = await response.text().catch(() => "");
153
228
  log.error(
154
229
  {
155
230
  status: response.status,
156
231
  sourceEventName: payload.sourceEventName,
157
- body: errorText.slice(0, 256),
232
+ body: responseText.slice(0, 256),
158
233
  },
159
234
  "Non-retryable error from push dispatch endpoint",
160
235
  );
161
236
  return {
162
237
  success: false,
163
- error: `HTTP ${response.status}: ${errorText.slice(0, 128)}`,
238
+ error: `HTTP ${response.status}: ${responseText.slice(0, 128)}`,
239
+ remotePushPlatforms: remotePushPlatforms(),
164
240
  };
165
241
  }
166
242
 
@@ -49,14 +49,16 @@ export class TelegramAdapter implements ChannelAdapter {
49
49
  // Attempt rich delivery with inline keyboard buttons.
50
50
  // On failure, fall back to plain text below.
51
51
  try {
52
- await sendTelegramReply(chatId, messageText, approval);
52
+ const sent = await sendTelegramReply(chatId, messageText, approval);
53
53
 
54
54
  log.info(
55
55
  { sourceEventName: payload.sourceEventName, chatId },
56
56
  "Telegram approval notification delivered with inline buttons",
57
57
  );
58
58
 
59
- return { success: true };
59
+ // The message id lets the delivery row address the card later
60
+ // (in-place withdrawal when the request resolves elsewhere).
61
+ return { success: true, messageId: sent.lastMessageId };
60
62
  } catch (richErr) {
61
63
  log.warn(
62
64
  { err: richErr, sourceEventName: payload.sourceEventName, chatId },
@@ -73,14 +75,14 @@ export class TelegramAdapter implements ChannelAdapter {
73
75
  ? `${messageText}\n\n${approval.plainTextFallback}`
74
76
  : messageText;
75
77
 
76
- await sendTelegramReply(chatId, fallbackText);
78
+ const sent = await sendTelegramReply(chatId, fallbackText);
77
79
 
78
80
  log.info(
79
81
  { sourceEventName: payload.sourceEventName, chatId },
80
82
  "Telegram notification delivered",
81
83
  );
82
84
 
83
- return { success: true };
85
+ return { success: true, messageId: sent.lastMessageId };
84
86
  } catch (err) {
85
87
  const message = err instanceof Error ? err.message : String(err);
86
88
  // A missing bot token means the operator simply hasn't configured
@@ -0,0 +1,219 @@
1
+ /**
2
+ * `chat.assistant_reply` producer: an APNs push for a finished reply the user
3
+ * is no longer looking at.
4
+ *
5
+ * Called at the end of a user-initiated turn (see
6
+ * `daemon/conversation-turn-finalize.ts`) and best-effort throughout: every
7
+ * failure is logged and swallowed, because a notification hiccup must never
8
+ * escalate a reply the client already received into a turn-level throw.
9
+ */
10
+
11
+ import type pino from "pino";
12
+
13
+ import { isAssistantFeatureFlagEnabled } from "../config/assistant-feature-flags.js";
14
+ import { getAttachmentMetadataForMessage } from "../persistence/attachments-store.js";
15
+ import {
16
+ getAttentionStateByConversationIds,
17
+ hasUnseenLatestAssistantMessage,
18
+ } from "../persistence/conversation-attention-store.js";
19
+ import {
20
+ type ConversationRow,
21
+ getConversation,
22
+ getMessageById,
23
+ parseMessageMetadata,
24
+ } from "../persistence/conversation-crud.js";
25
+ import {
26
+ isReplyPushIneligibleUserMessage,
27
+ resolveConversationKind,
28
+ } from "../persistence/conversation-types.js";
29
+ import { stringifyMessageContent } from "../persistence/message-content.js";
30
+ import { safeParseRecord } from "../util/json.js";
31
+ import { emitNotificationSignal } from "./emit-signal.js";
32
+ import {
33
+ sanitizeMessagePreview,
34
+ sanitizeNotificationTitle,
35
+ } from "./notification-utils.js";
36
+
37
+ /** Kill switch for this producer, on by default. */
38
+ const ASSISTANT_REPLY_PUSH_FLAG = "assistant-reply-push" as const;
39
+
40
+ /**
41
+ * Collapse whitespace runs ahead of the sanitizers' truncation: blank lines and
42
+ * list indentation would otherwise eat into the copy's length budget.
43
+ */
44
+ function collapseWhitespace(value: string): string {
45
+ return value.replace(/\s+/g, " ").trim();
46
+ }
47
+
48
+ /**
49
+ * Body for a reply whose only user-visible output is attachments. Generated
50
+ * files and images are linked to the assistant row by
51
+ * `resolveAssistantAttachments` before the turn's terminal SSE, and are
52
+ * exposed separately from the row's content blocks, so a file-generation reply
53
+ * reaches this producer with no text to preview. Reads attachment metadata
54
+ * only (no base64), and only once the text preview has already come up empty.
55
+ *
56
+ * Returns an empty string when the row has no attachments either, which is the
57
+ * genuinely-empty reply the caller suppresses.
58
+ */
59
+ function describeAttachmentOnlyReply(assistantMessageId: string): string {
60
+ const attachments = getAttachmentMetadataForMessage(assistantMessageId);
61
+ if (attachments.length === 0) {
62
+ return "";
63
+ }
64
+ if (attachments.length > 1) {
65
+ return `Sent ${attachments.length} attachments`;
66
+ }
67
+ // Filenames are model- and tool-authored, so one is sanitized before it can
68
+ // reach the lock screen. A filename that sanitizes away leaves generic copy
69
+ // rather than a dangling "Sent ".
70
+ const filename = sanitizeMessagePreview(
71
+ collapseWhitespace(attachments[0].originalFilename),
72
+ );
73
+ return filename ? `Sent ${filename}` : "Sent an attachment";
74
+ }
75
+
76
+ /**
77
+ * Read the markers the eligibility gate below consults off a persisted
78
+ * message's metadata column.
79
+ *
80
+ * `parseMessageMetadata` validates the whole column and yields nothing when
81
+ * any single field fails, so one unrecognized value would otherwise present
82
+ * here as "no metadata" and open every gate at once. The gate is a plain-record
83
+ * predicate, so a permissive read of the same JSON keeps it answering over
84
+ * whichever fields are intact.
85
+ */
86
+ function readSuppressionMarkers(
87
+ metadataJson: string | null,
88
+ ): Record<string, unknown> | undefined {
89
+ const validated = parseMessageMetadata(metadataJson);
90
+ if (validated) {
91
+ return validated;
92
+ }
93
+ return metadataJson ? safeParseRecord(metadataJson) : undefined;
94
+ }
95
+
96
+ export async function emitAssistantReplyNotification(params: {
97
+ conversationId: string;
98
+ assistantMessageId: string;
99
+ /**
100
+ * The row that opened the turn, threaded from the agent loop. Reading it by
101
+ * id rather than scanning back from the assistant row keeps a hidden or
102
+ * queued user message that landed mid-turn from being mistaken for the
103
+ * prompt this reply answers.
104
+ */
105
+ userMessageId: string | undefined;
106
+ /**
107
+ * True when this turn's reply streams to the app and nowhere else, so the
108
+ * initiating row's channel and voice markers no longer describe where the
109
+ * reply lands. See {@link isReplyPushIneligibleUserMessage}.
110
+ */
111
+ replyDeliveredInAppOnly?: boolean;
112
+ rlog: pino.Logger;
113
+ /** Row the caller already holds; re-read when omitted. */
114
+ conversation?: ConversationRow | null;
115
+ }): Promise<void> {
116
+ const { conversationId, assistantMessageId, userMessageId, rlog } = params;
117
+ try {
118
+ if (!isAssistantFeatureFlagEnabled(ASSISTANT_REPLY_PUSH_FLAG)) {
119
+ return;
120
+ }
121
+ if (!userMessageId) {
122
+ return;
123
+ }
124
+ const conversation = params.conversation ?? getConversation(conversationId);
125
+ if (!conversation) {
126
+ return;
127
+ }
128
+ // The other three kinds (memory consolidation, background, scheduled) each
129
+ // already have their own notification producer.
130
+ const kind = resolveConversationKind(
131
+ conversation.source,
132
+ conversation.conversationType,
133
+ );
134
+ if (kind !== "user") {
135
+ return;
136
+ }
137
+ const attention = getAttentionStateByConversationIds([conversationId]).get(
138
+ conversationId,
139
+ );
140
+ if (!hasUnseenLatestAssistantMessage(attention)) {
141
+ return;
142
+ }
143
+
144
+ const assistantRow = getMessageById(assistantMessageId, conversationId);
145
+ if (!assistantRow) {
146
+ return;
147
+ }
148
+
149
+ const initiatingMessage = getMessageById(userMessageId, conversationId);
150
+ if (!initiatingMessage) {
151
+ return;
152
+ }
153
+ const initiatingMetadata = readSuppressionMarkers(
154
+ initiatingMessage.metadata,
155
+ );
156
+ if (
157
+ isReplyPushIneligibleUserMessage(initiatingMetadata, {
158
+ replyDeliveredInAppOnly: params.replyDeliveredInAppOnly,
159
+ })
160
+ ) {
161
+ return;
162
+ }
163
+
164
+ // A reply whose output is entirely attachments has no text to preview, so
165
+ // fall back to naming them rather than suppressing a real reply. A reply
166
+ // with neither text nor attachments stays silent.
167
+ const preview =
168
+ sanitizeMessagePreview(
169
+ collapseWhitespace(stringifyMessageContent(assistantRow.content)),
170
+ ) ||
171
+ sanitizeMessagePreview(describeAttachmentOnlyReply(assistantMessageId));
172
+ if (!preview) {
173
+ return;
174
+ }
175
+
176
+ // Conversation titles are user-controlled and unbounded (renames, imports),
177
+ // so the title gets the same treatment as the body before it reaches the
178
+ // lock screen. Absent `requestedTitle` lets the decision branch derive a
179
+ // title from the body, which reads better than an empty or placeholder
180
+ // conversation title.
181
+ const requestedTitle = sanitizeNotificationTitle(
182
+ collapseWhitespace(conversation.title ?? ""),
183
+ );
184
+
185
+ await emitNotificationSignal({
186
+ sourceEventName: "chat.assistant_reply",
187
+ sourceChannel: "vellum",
188
+ // Deep-link target; the broadcaster validates it and merges it into
189
+ // `deepLinkTarget.conversationId`.
190
+ sourceContextId: conversationId,
191
+ attentionHints: {
192
+ requiresAction: false,
193
+ // Load-bearing for the iOS-only scope: `emit-signal.ts` force-adds the
194
+ // vellum channel for high/critical signals, which would widen this into
195
+ // an in-app banner on every client. Raising the urgency is the same as
196
+ // opting into v2.
197
+ urgency: "medium",
198
+ isAsyncBackground: false,
199
+ // The daemon cannot tell "still viewing" from "just left" at turn end.
200
+ // The viewing-on-iPhone case is covered by iOS suppressing remote
201
+ // pushes while the app is foregrounded.
202
+ visibleInSourceNow: false,
203
+ },
204
+ contextPayload: {
205
+ ...(requestedTitle ? { requestedTitle } : {}),
206
+ requestedMessage: preview,
207
+ },
208
+ // The pipeline dedupe window is a flat hour keyed on this alone, so it
209
+ // has to carry the message id or a second reply within the hour is
210
+ // silently dropped.
211
+ dedupeKey: `chat.assistant_reply:${conversationId}:${assistantMessageId}`,
212
+ });
213
+ } catch (err) {
214
+ rlog.warn(
215
+ { err, conversationId, messageId: assistantMessageId },
216
+ "Failed to emit assistant reply notification (non-fatal)",
217
+ );
218
+ }
219
+ }