@vellumai/assistant 0.8.3 → 0.8.4

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 (342) hide show
  1. package/docker-entrypoint.sh +0 -1
  2. package/node_modules/@vellumai/gateway-client/src/types.ts +2 -0
  3. package/openapi.yaml +610 -16
  4. package/package.json +1 -1
  5. package/src/__tests__/agent-loop-exit-reason.test.ts +4 -5
  6. package/src/__tests__/agent-loop-override-profile.test.ts +1 -1
  7. package/src/__tests__/agent-loop.test.ts +88 -3
  8. package/src/__tests__/anthropic-provider.test.ts +272 -0
  9. package/src/__tests__/approval-cascade.test.ts +1 -1
  10. package/src/__tests__/background-workers-disk-pressure.test.ts +2 -1
  11. package/src/__tests__/channel-delivery-store.test.ts +193 -0
  12. package/src/__tests__/channel-reply-delivery.test.ts +284 -5
  13. package/src/__tests__/channel-retry-sweep.test.ts +274 -1
  14. package/src/__tests__/compaction-events.test.ts +1 -1
  15. package/src/__tests__/compactor-preserved-tail-count.test.ts +110 -0
  16. package/src/__tests__/config-watcher.test.ts +1 -1
  17. package/src/__tests__/context-token-estimator.test.ts +91 -1
  18. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  19. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +54 -3
  20. package/src/__tests__/conversation-agent-loop-overflow.test.ts +31 -6
  21. package/src/__tests__/conversation-agent-loop.test.ts +25 -7
  22. package/src/__tests__/conversation-app-control-lifecycle.test.ts +1 -1
  23. package/src/__tests__/conversation-clean-command.test.ts +137 -0
  24. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
  25. package/src/__tests__/conversation-fork-crud.test.ts +161 -0
  26. package/src/__tests__/conversation-lifecycle.test.ts +1 -1
  27. package/src/__tests__/conversation-load-cleaned-at.test.ts +279 -0
  28. package/src/__tests__/conversation-load-history-repair.test.ts +1 -1
  29. package/src/__tests__/conversation-pairing.test.ts +2 -2
  30. package/src/__tests__/conversation-process-callsite.test.ts +1 -1
  31. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
  32. package/src/__tests__/conversation-queue.test.ts +1 -1
  33. package/src/__tests__/conversation-runtime-assembly.test.ts +264 -81
  34. package/src/__tests__/conversation-seed-composer.test.ts +66 -4
  35. package/src/__tests__/conversation-slash-commands.test.ts +36 -8
  36. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  37. package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
  38. package/src/__tests__/conversation-speed-override.test.ts +1 -1
  39. package/src/__tests__/conversation-surfaces-task-progress.test.ts +220 -0
  40. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
  41. package/src/__tests__/conversation-workspace-injection.test.ts +5 -1
  42. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +5 -1
  43. package/src/__tests__/credential-security-invariants.test.ts +6 -0
  44. package/src/__tests__/cu-unified-flow.test.ts +10 -1
  45. package/src/__tests__/dm-backfill.test.ts +64 -0
  46. package/src/__tests__/dm-persistence.test.ts +33 -0
  47. package/src/__tests__/document-find-replace.test.ts +501 -0
  48. package/src/__tests__/first-greeting.test.ts +23 -2
  49. package/src/__tests__/headless-browser-navigate.test.ts +172 -0
  50. package/src/__tests__/host-bash-proxy.test.ts +6 -0
  51. package/src/__tests__/host-browser-proxy.test.ts +10 -0
  52. package/src/__tests__/host-cu-proxy.test.ts +8 -1
  53. package/src/__tests__/host-file-proxy.test.ts +8 -1
  54. package/src/__tests__/host-transfer-proxy.test.ts +8 -1
  55. package/src/__tests__/identity-routes.test.ts +57 -0
  56. package/src/__tests__/inbound-slack-persistence.test.ts +3 -0
  57. package/src/__tests__/injector-chain.test.ts +2 -0
  58. package/src/__tests__/injector-document-comments.test.ts +378 -0
  59. package/src/__tests__/injector-pkb-v2-silenced.test.ts +4 -25
  60. package/src/__tests__/list-messages-attachments.test.ts +21 -17
  61. package/src/__tests__/list-messages-hidden-metadata.test.ts +217 -0
  62. package/src/__tests__/list-messages-page-latest.test.ts +130 -14
  63. package/src/__tests__/list-messages-tool-merge.test.ts +17 -16
  64. package/src/__tests__/llm-context-normalization.test.ts +0 -2
  65. package/src/__tests__/llm-resolver.test.ts +85 -1
  66. package/src/__tests__/log-export-routes.test.ts +99 -2
  67. package/src/__tests__/message-queue-steer.test.ts +114 -0
  68. package/src/__tests__/openai-provider.test.ts +105 -0
  69. package/src/__tests__/openai-responses-provider.test.ts +4 -4
  70. package/src/__tests__/outbound-slack-persistence.test.ts +187 -20
  71. package/src/__tests__/pending-interactions-resolved-event.test.ts +190 -0
  72. package/src/__tests__/platform.test.ts +0 -3
  73. package/src/__tests__/plugin-source-watcher.test.ts +302 -0
  74. package/src/__tests__/process-message-background-slack.test.ts +1 -51
  75. package/src/__tests__/process-message-display-content.test.ts +21 -16
  76. package/src/__tests__/server-history-render.test.ts +83 -4
  77. package/src/__tests__/steer-tool-repair.test.ts +249 -0
  78. package/src/__tests__/system-prompt.test.ts +51 -28
  79. package/src/__tests__/terminal-tools.test.ts +11 -1
  80. package/src/__tests__/thinking-block-replay.test.ts +113 -0
  81. package/src/__tests__/thread-backfill.test.ts +370 -22
  82. package/src/__tests__/tool-executor.test.ts +90 -1
  83. package/src/__tests__/tool-result-metadata-plumbing.test.ts +167 -0
  84. package/src/__tests__/twilio-routes.test.ts +1 -1
  85. package/src/__tests__/web-fetch.test.ts +2 -2
  86. package/src/__tests__/workspace-git-service.test.ts +88 -5
  87. package/src/__tests__/workspace-migration-088-deprecate-background-conversation-override.test.ts +158 -0
  88. package/src/agent/attachments.ts +1 -0
  89. package/src/agent/loop.ts +57 -20
  90. package/src/background-wake/next-wake.test.ts +289 -0
  91. package/src/background-wake/next-wake.ts +172 -0
  92. package/src/browser/operations.ts +15 -0
  93. package/src/cli/commands/__tests__/conversations-slack.test.ts +572 -0
  94. package/src/cli/commands/__tests__/memory-v2.test.ts +9 -12
  95. package/src/cli/commands/conversations.ts +128 -1
  96. package/src/cli/commands/inference-providers.ts +147 -1
  97. package/src/cli/commands/memory-v2.ts +308 -0
  98. package/src/cli/commands/notifications.ts +24 -2
  99. package/src/cli/utils/conversation-id.ts +17 -5
  100. package/src/config/bundled-skills/app-builder/SKILL.md +2 -2
  101. package/src/config/bundled-skills/document-editor/SKILL.md +115 -0
  102. package/src/config/bundled-skills/document-editor/TOOLS.json +240 -0
  103. package/src/config/bundled-skills/document-editor/tools/comment-list.ts +12 -0
  104. package/src/config/bundled-skills/document-editor/tools/comment-reply.ts +12 -0
  105. package/src/config/bundled-skills/document-editor/tools/comment-resolve.ts +12 -0
  106. package/src/config/bundled-skills/document-editor/tools/document-find.ts +12 -0
  107. package/src/config/bundled-skills/document-editor/tools/document-replace-text.ts +12 -0
  108. package/src/config/bundled-skills/media-processing/SKILL.md +8 -0
  109. package/src/config/bundled-skills/schedule/SKILL.md +8 -0
  110. package/src/config/bundled-tool-registry.ts +22 -12
  111. package/src/config/call-site-defaults.ts +19 -0
  112. package/src/config/feature-flag-registry.json +99 -3
  113. package/src/config/llm-resolver.ts +16 -2
  114. package/src/config/schemas/__tests__/memory-v2.test.ts +4 -0
  115. package/src/config/schemas/call-site-catalog.ts +21 -0
  116. package/src/config/schemas/llm.ts +3 -0
  117. package/src/config/schemas/memory-v2.ts +48 -1
  118. package/src/context/compactor.ts +8 -1
  119. package/src/context/token-estimator.ts +47 -4
  120. package/src/context/window-manager.ts +25 -0
  121. package/src/credential-health/credential-health-service.ts +34 -19
  122. package/src/daemon/__tests__/conversation-tool-setup.test.ts +66 -6
  123. package/src/daemon/__tests__/native-web-search-metadata.test.ts +357 -0
  124. package/src/daemon/__tests__/web-search-status-text.test.ts +287 -0
  125. package/src/daemon/conversation-agent-loop-handlers.ts +153 -23
  126. package/src/daemon/conversation-agent-loop.ts +223 -54
  127. package/src/daemon/conversation-lifecycle.ts +142 -116
  128. package/src/daemon/conversation-messaging.ts +3 -0
  129. package/src/daemon/conversation-process.ts +273 -0
  130. package/src/daemon/conversation-queue-manager.ts +14 -0
  131. package/src/daemon/conversation-runtime-assembly.ts +135 -75
  132. package/src/daemon/conversation-slash.ts +37 -5
  133. package/src/daemon/conversation-surfaces.ts +45 -2
  134. package/src/daemon/conversation-tool-setup.ts +7 -0
  135. package/src/daemon/conversation.ts +42 -5
  136. package/src/daemon/first-greeting.ts +10 -0
  137. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +498 -0
  138. package/src/daemon/handlers/config-a2a.ts +160 -0
  139. package/src/daemon/handlers/config-model.test.ts +1 -0
  140. package/src/daemon/handlers/conversations.ts +79 -0
  141. package/src/daemon/handlers/shared.ts +92 -29
  142. package/src/daemon/host-bash-proxy.ts +1 -1
  143. package/src/daemon/host-cu-proxy.ts +1 -1
  144. package/src/daemon/host-file-proxy.ts +1 -1
  145. package/src/daemon/host-transfer-proxy.ts +1 -1
  146. package/src/daemon/lifecycle.ts +18 -4
  147. package/src/daemon/message-protocol.ts +4 -0
  148. package/src/daemon/message-types/conversations.ts +8 -0
  149. package/src/daemon/message-types/document-comments.ts +50 -0
  150. package/src/daemon/message-types/messages.ts +68 -1
  151. package/src/daemon/message-types/surfaces.ts +3 -1
  152. package/src/daemon/message-types/web-activity.ts +57 -0
  153. package/src/daemon/plugin-source-watcher.ts +135 -3
  154. package/src/daemon/process-message.ts +69 -12
  155. package/src/daemon/query-complexity-router.ts +75 -0
  156. package/src/daemon/trust-context.ts +6 -0
  157. package/src/documents/document-comments-store.test.ts +338 -0
  158. package/src/documents/document-comments-store.ts +237 -0
  159. package/src/documents/document-store.ts +202 -0
  160. package/src/heartbeat/__tests__/heartbeat-service.test.ts +0 -1
  161. package/src/heartbeat/heartbeat-service.ts +1 -0
  162. package/src/home/__tests__/suggested-prompts.test.ts +33 -2
  163. package/src/home/feed-types.ts +6 -1
  164. package/src/home/home-content-refresh.ts +52 -0
  165. package/src/home/home-greeting-cache.ts +69 -0
  166. package/src/home/home-greeting.ts +94 -0
  167. package/src/home/suggested-prompts.ts +177 -9
  168. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +135 -2
  169. package/src/memory/__tests__/memory-retrospective-job.test.ts +320 -6
  170. package/src/memory/conversation-crud.ts +133 -43
  171. package/src/memory/db-init.ts +16 -0
  172. package/src/memory/delivery-crud.ts +41 -0
  173. package/src/memory/delivery-status.ts +141 -15
  174. package/src/memory/external-conversation-store.ts +32 -1
  175. package/src/memory/jobs-worker.ts +21 -1
  176. package/src/memory/memory-retrospective-constants.ts +28 -0
  177. package/src/memory/memory-retrospective-enqueue.ts +3 -2
  178. package/src/memory/memory-retrospective-job.ts +408 -18
  179. package/src/memory/memory-retrospective-startup-cleanup.ts +3 -3
  180. package/src/memory/memory-v2-activation-log-store.ts +26 -8
  181. package/src/memory/migrations/100-core-tables.ts +1 -0
  182. package/src/memory/migrations/109-external-conversation-bindings.ts +1 -0
  183. package/src/memory/migrations/253-conversation-last-notified-profile.ts +15 -0
  184. package/src/memory/migrations/253-document-comments.ts +47 -0
  185. package/src/memory/migrations/254-external-conversation-binding-chat-name.ts +43 -0
  186. package/src/memory/migrations/255-channel-inbound-delivery-attempts.ts +24 -0
  187. package/src/memory/migrations/256-memory-v2-injection-events.ts +113 -0
  188. package/src/memory/migrations/257-strip-base-url-non-openai-compatible.ts +22 -0
  189. package/src/memory/migrations/258-onboarding-events-prior-assistants.ts +13 -0
  190. package/src/memory/migrations/259-conversation-cleaned-at.ts +33 -0
  191. package/src/memory/migrations/index.ts +17 -0
  192. package/src/memory/migrations/registry.ts +25 -0
  193. package/src/memory/onboarding-events-store.ts +7 -0
  194. package/src/memory/schema/calls.ts +1 -0
  195. package/src/memory/schema/conversations.ts +3 -0
  196. package/src/memory/schema/infrastructure.ts +1 -0
  197. package/src/memory/v2/__tests__/injection-events.test.ts +318 -0
  198. package/src/memory/v2/__tests__/injection.test.ts +31 -14
  199. package/src/memory/v2/__tests__/page-index.test.ts +365 -1
  200. package/src/memory/v2/__tests__/router.test.ts +489 -1
  201. package/src/memory/v2/consolidation-job.ts +14 -0
  202. package/src/memory/v2/injection-events.ts +101 -0
  203. package/src/memory/v2/injection.ts +21 -10
  204. package/src/memory/v2/page-index.ts +209 -7
  205. package/src/memory/v2/page-store.ts +18 -0
  206. package/src/memory/v2/router.ts +209 -55
  207. package/src/messaging/providers/index.ts +7 -1
  208. package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +329 -3
  209. package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +34 -1
  210. package/src/messaging/providers/slack/adapter.ts +178 -25
  211. package/src/messaging/providers/slack/api.test.ts +54 -0
  212. package/src/messaging/providers/slack/api.ts +119 -3
  213. package/src/messaging/providers/slack/client.ts +12 -0
  214. package/src/messaging/providers/slack/deep-link.ts +20 -1
  215. package/src/messaging/providers/slack/message-metadata.test.ts +48 -0
  216. package/src/messaging/providers/slack/message-metadata.ts +156 -0
  217. package/src/messaging/providers/slack/render-transcript.test.ts +107 -75
  218. package/src/messaging/providers/slack/render-transcript.ts +176 -49
  219. package/src/messaging/providers/slack/send.test.ts +77 -0
  220. package/src/messaging/providers/slack/send.ts +8 -2
  221. package/src/messaging/providers/slack/types.ts +14 -0
  222. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +4 -1
  223. package/src/notifications/__tests__/home-feed-side-effect.test.ts +116 -54
  224. package/src/notifications/conversation-seed-composer.ts +14 -2
  225. package/src/notifications/deferred-emit.ts +135 -0
  226. package/src/notifications/emit-signal.ts +9 -1
  227. package/src/notifications/home-feed-side-effect.ts +60 -30
  228. package/src/oauth/connect-orchestrator.ts +3 -0
  229. package/src/oauth/credential-token-resolver.ts +2 -0
  230. package/src/oauth/manual-token-connection.ts +19 -0
  231. package/src/oauth/oauth-store.ts +12 -0
  232. package/src/oauth/seed-providers.ts +22 -0
  233. package/src/permissions/prompter.ts +5 -2
  234. package/src/permissions/secret-prompter.ts +4 -1
  235. package/src/plugins/defaults/injectors.ts +82 -9
  236. package/src/prompts/__tests__/system-prompt.test.ts +46 -2
  237. package/src/prompts/normalize-onboarding.ts +40 -0
  238. package/src/prompts/sections.ts +32 -14
  239. package/src/prompts/system-prompt.ts +105 -68
  240. package/src/prompts/template-detection.ts +37 -0
  241. package/src/prompts/templates/BOOTSTRAP-CONTENT-AUTOMATION.md +141 -0
  242. package/src/prompts/templates/BOOTSTRAP.md +8 -0
  243. package/src/prompts/templates/VOICE.md +3 -0
  244. package/src/prompts/templates/system-sections.ts +53 -3
  245. package/src/providers/anthropic/client.ts +132 -5
  246. package/src/providers/fireworks/client.ts +20 -2
  247. package/src/providers/inference/__tests__/base-url-route-validation.test.ts +342 -0
  248. package/src/providers/inference/__tests__/base-url-security.test.ts +189 -0
  249. package/src/providers/inference/__tests__/codex-token-refresh.test.ts +254 -0
  250. package/src/providers/inference/adapter-factory.ts +15 -1
  251. package/src/providers/inference/auth.ts +3 -3
  252. package/src/providers/inference/codex-token-refresh.ts +128 -0
  253. package/src/providers/inference/resolve-auth.ts +49 -6
  254. package/src/providers/model-catalog.ts +48 -1
  255. package/src/providers/openai/chat-completions-provider.ts +57 -20
  256. package/src/providers/openai/responses-provider.ts +9 -3
  257. package/src/providers/openrouter/client.ts +5 -1
  258. package/src/providers/types.ts +25 -0
  259. package/src/runtime/__tests__/agent-wake.test.ts +214 -0
  260. package/src/runtime/__tests__/background-job-runner.test.ts +128 -0
  261. package/src/runtime/agent-wake.ts +151 -56
  262. package/src/runtime/auth/route-policy.ts +7 -3
  263. package/src/runtime/background-job-runner.ts +26 -0
  264. package/src/runtime/channel-reply-delivery.ts +182 -47
  265. package/src/runtime/channel-retry-sweep.ts +141 -16
  266. package/src/runtime/http-types.ts +7 -4
  267. package/src/runtime/pending-interactions.ts +51 -8
  268. package/src/runtime/routes/__tests__/content-source-routes.test.ts +162 -0
  269. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +55 -1
  270. package/src/runtime/routes/__tests__/memory-v2-routes.test.ts +14 -0
  271. package/src/runtime/routes/__tests__/memory-v2-simulate-route.test.ts +271 -0
  272. package/src/runtime/routes/__tests__/sanity-routes.test.ts +280 -0
  273. package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +266 -0
  274. package/src/runtime/routes/approval-routes.ts +4 -1
  275. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +246 -0
  276. package/src/runtime/routes/content-source-routes.ts +78 -0
  277. package/src/runtime/routes/conversation-cli-routes.ts +146 -1
  278. package/src/runtime/routes/conversation-query-routes.ts +60 -1
  279. package/src/runtime/routes/conversation-routes.ts +281 -76
  280. package/src/runtime/routes/document-comments-routes.ts +287 -0
  281. package/src/runtime/routes/documents-routes.ts +33 -0
  282. package/src/runtime/routes/home-feed-routes.ts +6 -3
  283. package/src/runtime/routes/host-app-control-routes.ts +1 -1
  284. package/src/runtime/routes/host-browser-routes.ts +8 -1
  285. package/src/runtime/routes/identity-routes.ts +21 -0
  286. package/src/runtime/routes/inbound-message-handler.ts +288 -58
  287. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +365 -6
  288. package/src/runtime/routes/inbound-stages/background-dispatch.ts +283 -82
  289. package/src/runtime/routes/index.ts +12 -4
  290. package/src/runtime/routes/inference-provider-connection-routes.ts +63 -7
  291. package/src/runtime/routes/integrations/a2a.ts +60 -1
  292. package/src/runtime/routes/log-export-routes.ts +39 -0
  293. package/src/runtime/routes/memory-v2-routes.ts +217 -0
  294. package/src/runtime/routes/notification-routes.ts +19 -2
  295. package/src/runtime/routes/question-routes.ts +4 -1
  296. package/src/runtime/routes/sanity-routes.ts +159 -0
  297. package/src/runtime/routes/slack-channel-routes.ts +187 -0
  298. package/src/runtime/services/conversation-serializer.ts +30 -4
  299. package/src/schedule/integration-status.ts +3 -1
  300. package/src/security/__tests__/oauth2-device-code.test.ts +479 -0
  301. package/src/security/oauth2-device-code.ts +307 -0
  302. package/src/security/oauth2.ts +26 -9
  303. package/src/security/secure-keys.ts +5 -0
  304. package/src/skills/catalog-install.ts +6 -2
  305. package/src/tools/browser/__tests__/pinned-tabs.test.ts +80 -0
  306. package/src/tools/browser/browser-execution.ts +93 -0
  307. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +28 -0
  308. package/src/tools/browser/cdp-client/__tests__/types.test.ts +1 -0
  309. package/src/tools/browser/cdp-client/cdp-inspect-client.ts +10 -0
  310. package/src/tools/browser/cdp-client/extension-cdp-client.ts +15 -1
  311. package/src/tools/browser/cdp-client/factory.ts +87 -3
  312. package/src/tools/browser/cdp-client/local-cdp-client.ts +9 -0
  313. package/src/tools/browser/cdp-client/types.ts +36 -0
  314. package/src/tools/browser/pinned-tabs.ts +90 -0
  315. package/src/tools/document/document-comment-tool.test.ts +379 -0
  316. package/src/tools/document/document-comment-tool.ts +156 -0
  317. package/src/tools/document/document-tool.ts +128 -2
  318. package/src/tools/network/__tests__/web-fetch-metadata.test.ts +229 -0
  319. package/src/tools/network/__tests__/web-search-metadata.test.ts +346 -0
  320. package/src/tools/network/domain-normalize.ts +17 -0
  321. package/src/tools/network/web-fetch.ts +213 -64
  322. package/src/tools/network/web-search.ts +191 -66
  323. package/src/tools/terminal/safe-env.ts +3 -2
  324. package/src/tools/tool-approval-handler.ts +19 -12
  325. package/src/tools/types.ts +4 -0
  326. package/src/tools/ui-surface/definitions.ts +3 -1
  327. package/src/types/onboarding-context.ts +4 -0
  328. package/src/util/__tests__/favicon.test.ts +84 -0
  329. package/src/util/favicon.ts +40 -0
  330. package/src/util/platform.ts +0 -5
  331. package/src/workspace/git-service.ts +75 -4
  332. package/src/workspace/migrations/088-deprecate-background-conversation-override.ts +103 -0
  333. package/src/workspace/migrations/registry.ts +2 -0
  334. package/src/config/bundled-skills/document/SKILL.md +0 -54
  335. package/src/config/bundled-skills/document/TOOLS.json +0 -106
  336. package/src/daemon/seed-files.ts +0 -18
  337. package/src/runtime/routes/interface-routes.ts +0 -43
  338. /package/src/config/bundled-skills/{document → document-editor}/tools/document-create.ts +0 -0
  339. /package/src/config/bundled-skills/{document → document-editor}/tools/document-delete.ts +0 -0
  340. /package/src/config/bundled-skills/{document → document-editor}/tools/document-list.ts +0 -0
  341. /package/src/config/bundled-skills/{document → document-editor}/tools/document-read.ts +0 -0
  342. /package/src/config/bundled-skills/{document → document-editor}/tools/document-update.ts +0 -0
@@ -2,10 +2,7 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  import type { FeedItem } from "../../home/feed-types.js";
4
4
  import type { NotificationSignal } from "../signal.js";
5
- import type {
6
- NotificationDecision,
7
- NotificationDeliveryResult,
8
- } from "../types.js";
5
+ import type { NotificationDecision } from "../types.js";
9
6
 
10
7
  // ── Module mocks ───────────────────────────────────────────────────────
11
8
  //
@@ -97,7 +94,7 @@ describe("writeHomeFeedItemForSignal", () => {
97
94
  },
98
95
  });
99
96
 
100
- const item = await writeHomeFeedItemForSignal(signal, decision, []);
97
+ const item = await writeHomeFeedItemForSignal(signal, decision);
101
98
 
102
99
  expect(conversationLookups).toEqual(["conv-source-1"]);
103
100
  expect(item).not.toBeNull();
@@ -114,6 +111,10 @@ describe("writeHomeFeedItemForSignal", () => {
114
111
  expect(appended.title).toBe("Background job done");
115
112
  expect(appended.summary).toBe("Summary of what happened.");
116
113
  expect(appended.urgency).toBe("medium");
114
+ // The button in the home detail panel navigates to the source
115
+ // conversation that emitted the notification, not the conversation the
116
+ // notification pipeline spawned to handle it.
117
+ expect(appended.conversationId).toBe("conv-source-1");
117
118
  expect(typeof appended.timestamp).toBe("string");
118
119
  expect(appended.createdAt).toBe(appended.timestamp);
119
120
  });
@@ -129,15 +130,16 @@ describe("writeHomeFeedItemForSignal", () => {
129
130
  },
130
131
  });
131
132
 
132
- const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
133
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
133
134
 
134
135
  expect(item).toBeNull();
135
136
  expect(appendCalls).toHaveLength(0);
136
137
  });
137
138
 
138
139
  test("isAsyncBackground hint writes even when sourceContextId does not resolve", async () => {
139
- // No conversation row matches; the conversation lookup is bypassed
140
- // entirely because the hint short-circuits the filter.
140
+ // Source lookup throws treated as non-navigable, so the item lands
141
+ // without a `conversationId` and the "Go to Thread" button hides on the
142
+ // client. The async-background hint still forces the mirror.
141
143
  conversationLookupShouldThrow = true;
142
144
  const signal = makeSignal({
143
145
  sourceContextId: "not-a-conversation-id",
@@ -154,21 +156,22 @@ describe("writeHomeFeedItemForSignal", () => {
154
156
  },
155
157
  });
156
158
 
157
- const item = await writeHomeFeedItemForSignal(signal, decision, []);
159
+ const item = await writeHomeFeedItemForSignal(signal, decision);
158
160
 
159
161
  expect(item).not.toBeNull();
160
162
  expect(appendCalls).toHaveLength(1);
161
163
  expect(appendCalls[0]!.urgency).toBe("high");
162
- // The async-background short-circuit must not consult the conversation store.
163
- expect(conversationLookups).toHaveLength(0);
164
+ expect(appendCalls[0]!.conversationId).toBeUndefined();
165
+ expect(conversationLookups).toEqual(["not-a-conversation-id"]);
164
166
  });
165
167
 
166
168
  test("assistant_tool source mirrors to the home feed even without a background conversation or async hint", async () => {
167
169
  // Regression: the `notifications send` CLI/skill emits with
168
170
  // `sourceChannel: "assistant_tool"`, a synthetic `cli-<ts>` source
169
171
  // context id that does not resolve to a conversation, and
170
- // `isAsyncBackground: false`. Before the fix, `shouldMirrorToHomeFeed`
171
- // returned `false` for this shape and the Inbox stayed empty.
172
+ // `isAsyncBackground: false`. The assistant_tool channel forces the
173
+ // mirror; the source-id lookup misses so the item lands without a
174
+ // `conversationId` and the "Go to Thread" button hides on the client.
172
175
  conversationRow = null;
173
176
  const signal = makeSignal({
174
177
  sourceChannel: "assistant_tool",
@@ -188,47 +191,106 @@ describe("writeHomeFeedItemForSignal", () => {
188
191
  },
189
192
  });
190
193
 
191
- const item = await writeHomeFeedItemForSignal(signal, decision, []);
194
+ const item = await writeHomeFeedItemForSignal(signal, decision);
192
195
 
193
196
  expect(item).not.toBeNull();
194
197
  expect(appendCalls).toHaveLength(1);
195
198
  expect(appendCalls[0]!.title).toBe("Shared from CLI");
196
199
  expect(appendCalls[0]!.noteworthy).toBe(true);
197
- // The assistant_tool short-circuit must not consult the conversation store.
198
- expect(conversationLookups).toHaveLength(0);
200
+ expect(appendCalls[0]!.conversationId).toBeUndefined();
201
+ expect(conversationLookups).toEqual(["cli-12345"]);
199
202
  });
200
203
 
201
- test("vellum delivery result conversationId propagates onto the feed item", async () => {
202
- conversationRow = { conversationType: "background" };
203
- const signal = makeSignal();
204
+ test("source conversation id does not propagate when the lookup misses", async () => {
205
+ // When `sourceContextId` does not resolve to a real conversation row
206
+ // (e.g. scheduler job id, watcher event id), the item is still mirrored
207
+ // via the `isAsyncBackground` hint but `conversationId` stays undefined
208
+ // so the client hides the "Go to Thread" affordance.
209
+ conversationRow = null;
210
+ const signal = makeSignal({
211
+ sourceContextId: "scheduler-job-42",
212
+ attentionHints: {
213
+ requiresAction: false,
214
+ urgency: "medium",
215
+ isAsyncBackground: true,
216
+ visibleInSourceNow: false,
217
+ },
218
+ });
204
219
  const decision = makeDecision({
205
220
  renderedCopy: {
206
221
  vellum: { title: "Routed title", body: "Routed body" },
207
222
  },
208
223
  });
209
- const deliveryResults: NotificationDeliveryResult[] = [
210
- {
211
- channel: "telegram",
212
- destination: "chat-1",
213
- status: "sent",
214
- conversationId: "conv-telegram-1",
224
+
225
+ const item = await writeHomeFeedItemForSignal(signal, decision);
226
+
227
+ expect(item?.conversationId).toBeUndefined();
228
+ expect(appendCalls[0]!.conversationId).toBeUndefined();
229
+ expect(conversationLookups).toEqual(["scheduler-job-42"]);
230
+ });
231
+
232
+ test("falls back to the paired delivery conversation when sourceContextId does not resolve", async () => {
233
+ // Regression: producers whose `sourceContextId` is a sentinel string
234
+ // (heartbeat startup `"heartbeat"`, credential health `connectionId`,
235
+ // watcher `watcher-<ts>`, scheduler retries-exhausted `jobId`, sweep
236
+ // job id) never resolve via `getConversation`. The notification
237
+ // broadcaster pairs each vellum delivery with a real conversation
238
+ // before the home-feed write runs, so the caller threads that paired
239
+ // id through as the fallback — the "Go to Convo" button now points at
240
+ // the conversation the notification was actually delivered into.
241
+ conversationRow = null;
242
+ const signal = makeSignal({
243
+ sourceChannel: "assistant_tool",
244
+ sourceEventName: "assistant.share",
245
+ sourceContextId: "watcher-1700000000",
246
+ contextPayload: { title: "Watcher alert" },
247
+ attentionHints: {
248
+ requiresAction: false,
249
+ urgency: "medium",
250
+ isAsyncBackground: true,
251
+ visibleInSourceNow: false,
252
+ },
253
+ });
254
+ const decision = makeDecision({
255
+ renderedCopy: {
256
+ vellum: { title: "Watcher alert", body: "Watcher body" },
215
257
  },
216
- {
217
- channel: "vellum",
218
- destination: "vellum-client",
219
- status: "sent",
220
- conversationId: "conv-vellum-1",
258
+ });
259
+
260
+ const item = await writeHomeFeedItemForSignal(
261
+ signal,
262
+ decision,
263
+ "paired-delivery-conv-id",
264
+ );
265
+
266
+ expect(item).not.toBeNull();
267
+ expect(appendCalls).toHaveLength(1);
268
+ expect(appendCalls[0]!.conversationId).toBe("paired-delivery-conv-id");
269
+ });
270
+
271
+ test("source conversation id wins over the paired delivery fallback when both are available", async () => {
272
+ // When the producer's `sourceContextId` already points at a real
273
+ // conversation (the canonical "where the work happened"), prefer it
274
+ // over the paired delivery — the fallback is only meant to fill the
275
+ // gap for sentinel-id producers.
276
+ conversationRow = { conversationType: "background" };
277
+ const signal = makeSignal({
278
+ contextPayload: { title: "Background job done" },
279
+ });
280
+ const decision = makeDecision({
281
+ renderedCopy: {
282
+ vellum: { title: "Background job done", body: "Summary." },
221
283
  },
222
- ];
284
+ });
223
285
 
224
286
  const item = await writeHomeFeedItemForSignal(
225
287
  signal,
226
288
  decision,
227
- deliveryResults,
289
+ "paired-delivery-conv-id",
228
290
  );
229
291
 
230
- expect(item?.conversationId).toBe("conv-vellum-1");
231
- expect(appendCalls[0]!.conversationId).toBe("conv-vellum-1");
292
+ expect(item).not.toBeNull();
293
+ expect(appendCalls[0]!.conversationId).toBe("conv-source-1");
232
294
  });
233
295
 
234
296
  test("returns null and does not write when no rendered copy or payload title/body is present", async () => {
@@ -238,7 +300,7 @@ describe("writeHomeFeedItemForSignal", () => {
238
300
  contextPayload: {},
239
301
  });
240
302
 
241
- const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
303
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
242
304
 
243
305
  expect(item).toBeNull();
244
306
  expect(appendCalls).toHaveLength(0);
@@ -251,7 +313,7 @@ describe("writeHomeFeedItemForSignal", () => {
251
313
  contextPayload: { title: "Real title" },
252
314
  });
253
315
 
254
- const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
316
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
255
317
 
256
318
  expect(item).toBeNull();
257
319
  expect(appendCalls).toHaveLength(0);
@@ -268,7 +330,7 @@ describe("writeHomeFeedItemForSignal", () => {
268
330
  contextPayload: { body: "Real body" },
269
331
  });
270
332
 
271
- const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
333
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
272
334
 
273
335
  expect(item).not.toBeNull();
274
336
  expect(appendCalls).toHaveLength(1);
@@ -294,7 +356,7 @@ describe("writeHomeFeedItemForSignal", () => {
294
356
  },
295
357
  });
296
358
 
297
- const item = await writeHomeFeedItemForSignal(signal, decision, []);
359
+ const item = await writeHomeFeedItemForSignal(signal, decision);
298
360
 
299
361
  expect(item).not.toBeNull();
300
362
  expect(appendCalls).toHaveLength(1);
@@ -314,7 +376,7 @@ describe("writeHomeFeedItemForSignal", () => {
314
376
  },
315
377
  });
316
378
 
317
- const item = await writeHomeFeedItemForSignal(signal, decision, []);
379
+ const item = await writeHomeFeedItemForSignal(signal, decision);
318
380
 
319
381
  expect(item).toBeNull();
320
382
  expect(appendCalls).toHaveLength(0);
@@ -340,7 +402,7 @@ describe("writeHomeFeedItemForSignal", () => {
340
402
  },
341
403
  });
342
404
 
343
- const item = await writeHomeFeedItemForSignal(signal, decision, []);
405
+ const item = await writeHomeFeedItemForSignal(signal, decision);
344
406
 
345
407
  expect(item).not.toBeNull();
346
408
  expect(appendCalls).toHaveLength(1);
@@ -372,7 +434,7 @@ describe("writeHomeFeedItemForSignal", () => {
372
434
  },
373
435
  });
374
436
 
375
- const item = await writeHomeFeedItemForSignal(signal, decision, []);
437
+ const item = await writeHomeFeedItemForSignal(signal, decision);
376
438
 
377
439
  expect(item).not.toBeNull();
378
440
  expect(appendCalls).toHaveLength(1);
@@ -400,7 +462,7 @@ describe("writeHomeFeedItemForSignal", () => {
400
462
  },
401
463
  });
402
464
 
403
- const item = await writeHomeFeedItemForSignal(signal, decision, []);
465
+ const item = await writeHomeFeedItemForSignal(signal, decision);
404
466
 
405
467
  expect(item).toBeNull();
406
468
  expect(appendCalls).toHaveLength(0);
@@ -423,7 +485,7 @@ describe("writeHomeFeedItemForSignal", () => {
423
485
  },
424
486
  });
425
487
 
426
- const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
488
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
427
489
 
428
490
  expect(item).not.toBeNull();
429
491
  expect(appendCalls).toHaveLength(1);
@@ -438,7 +500,7 @@ describe("writeHomeFeedItemForSignal", () => {
438
500
  contextPayload: { title: "Payload title", body: "Payload body" },
439
501
  });
440
502
 
441
- const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
503
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
442
504
 
443
505
  expect(item).not.toBeNull();
444
506
  expect(item?.title).toBe("Payload title");
@@ -456,7 +518,7 @@ describe("writeHomeFeedItemForSignal", () => {
456
518
  contextPayload: { title: "Tool share", body: "Body" },
457
519
  });
458
520
 
459
- const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
521
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
460
522
 
461
523
  expect(item?.noteworthy).toBe(true);
462
524
  expect(appendCalls[0]!.noteworthy).toBe(true);
@@ -470,7 +532,7 @@ describe("writeHomeFeedItemForSignal", () => {
470
532
  contextPayload: { title: "Tool share", body: "Body" },
471
533
  });
472
534
 
473
- const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
535
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
474
536
 
475
537
  expect(item?.fromAssistant).toBe(true);
476
538
  expect(appendCalls[0]!.fromAssistant).toBe(true);
@@ -484,7 +546,7 @@ describe("writeHomeFeedItemForSignal", () => {
484
546
  contextPayload: { title: "Reminder", body: "Time to do thing" },
485
547
  });
486
548
 
487
- const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
549
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
488
550
 
489
551
  expect(item?.fromAssistant).toBe(false);
490
552
  expect(appendCalls[0]!.fromAssistant).toBe(false);
@@ -498,7 +560,7 @@ describe("writeHomeFeedItemForSignal", () => {
498
560
  contextPayload: { title: "Reminder", body: "Time to do thing" },
499
561
  });
500
562
 
501
- const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
563
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
502
564
 
503
565
  expect(item?.noteworthy).toBe(false);
504
566
  expect(appendCalls[0]!.noteworthy).toBe(false);
@@ -512,7 +574,7 @@ describe("writeHomeFeedItemForSignal", () => {
512
574
  contextPayload: { title: "Question", body: "Approve?" },
513
575
  });
514
576
 
515
- const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
577
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
516
578
 
517
579
  expect(item?.noteworthy).toBe(true);
518
580
  expect(appendCalls[0]!.noteworthy).toBe(true);
@@ -532,7 +594,7 @@ describe("writeHomeFeedItemForSignal", () => {
532
594
  },
533
595
  });
534
596
 
535
- const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
597
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
536
598
 
537
599
  expect(item?.noteworthy).toBe(true);
538
600
  expect(appendCalls[0]!.noteworthy).toBe(true);
@@ -552,7 +614,7 @@ describe("writeHomeFeedItemForSignal", () => {
552
614
  },
553
615
  });
554
616
 
555
- const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
617
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
556
618
 
557
619
  expect(item?.noteworthy).toBe(false);
558
620
  expect(appendCalls[0]!.noteworthy).toBe(false);
@@ -578,7 +640,7 @@ describe("writeHomeFeedItemForSignal", () => {
578
640
  },
579
641
  });
580
642
 
581
- const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
643
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
582
644
 
583
645
  expect(item?.noteworthy).toBe(false);
584
646
  expect(appendCalls[0]!.noteworthy).toBe(false);
@@ -600,7 +662,7 @@ describe("writeHomeFeedItemForSignal", () => {
600
662
  },
601
663
  });
602
664
 
603
- const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
665
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
604
666
 
605
667
  expect(item?.noteworthy).toBe(true);
606
668
  expect(appendCalls[0]!.noteworthy).toBe(true);
@@ -614,7 +676,7 @@ describe("writeHomeFeedItemForSignal", () => {
614
676
  contextPayload: { title: "Credential expired", body: "Reconnect" },
615
677
  });
616
678
 
617
- const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
679
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
618
680
 
619
681
  expect(item?.noteworthy).toBe(true);
620
682
  expect(appendCalls[0]!.noteworthy).toBe(true);
@@ -156,8 +156,20 @@ export function composeConversationSeed(
156
156
 
157
157
  if (verbosity === "rich") {
158
158
  const parts: string[] = [];
159
- if (copy.title && copy.title !== "Notification") parts.push(copy.title);
160
- if (copy.body) parts.push(copy.body);
159
+ const usableTitle =
160
+ copy.title && copy.title !== "Notification" ? copy.title : "";
161
+ const hasBody = Boolean(copy.body && copy.body.trim());
162
+ // copy.title is used as the conversation header in chat surfaces
163
+ // whenever copy.conversationTitle is absent (see
164
+ // conversation-pairing.ts), so prepending it here would render it
165
+ // twice — once in the header bar and once at the start of the
166
+ // bubble. Skip it in that case, unless there's no body and we'd
167
+ // otherwise produce an empty seed. Treat whitespace-only bodies as
168
+ // empty so the title still populates the bubble.
169
+ if (usableTitle && (copy.conversationTitle || !hasBody)) {
170
+ parts.push(usableTitle);
171
+ }
172
+ if (hasBody) parts.push(copy.body);
161
173
  const alreadyMentionsAction = parts.some((part) =>
162
174
  /\baction required\b/i.test(part),
163
175
  );
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Per-conversation notification buffer used to keep a background job's
3
+ * "success" notification from racing the runner's `activity.failed` when
4
+ * the job times out after the model already invoked `notifications send`.
5
+ *
6
+ * `registerDeferredConversation` arms the buffer before the LLM turn.
7
+ * `bufferIfDeferred` is called from the IPC route handler — it buffers when
8
+ * armed, swallows when tombstoned (post-discard grace window), and returns
9
+ * null otherwise so the route emits normally.
10
+ * `commitDeferredConversation` flushes on success; `discardDeferredConversation`
11
+ * drops on failure and tombstones briefly to catch late tool calls that
12
+ * arrive after `processMessage` keeps running past the runner's timeout.
13
+ */
14
+
15
+ import { v4 as uuid } from "uuid";
16
+
17
+ import { getLogger } from "../util/logger.js";
18
+ import {
19
+ emitNotificationSignal,
20
+ type EmitSignalParams,
21
+ type EmitSignalResult,
22
+ } from "./emit-signal.js";
23
+
24
+ const log = getLogger("notifications-deferred-emit");
25
+
26
+ // How long after the last observed late notification we keep the tombstone
27
+ // alive. `work` in `runBackgroundJob` is not cancelled on timeout — it can
28
+ // continue running and emit skill calls indefinitely. We refresh the timer
29
+ // on every late arrival, so the tombstone persists as long as the turn is
30
+ // still draining and only expires once the conversation has been quiet for
31
+ // this long.
32
+ const TOMBSTONE_TTL_MS = 5 * 60 * 1000;
33
+
34
+ type BufferEntry =
35
+ | { state: "buffered"; items: EmitSignalParams<string>[] }
36
+ | { state: "tombstoned"; timer: ReturnType<typeof setTimeout> };
37
+
38
+ const buffers = new Map<string, BufferEntry>();
39
+
40
+ function scheduleTombstoneEviction(
41
+ conversationId: string,
42
+ ): ReturnType<typeof setTimeout> {
43
+ const timer = setTimeout(() => {
44
+ const cur = buffers.get(conversationId);
45
+ if (cur?.state === "tombstoned") buffers.delete(conversationId);
46
+ }, TOMBSTONE_TTL_MS);
47
+ timer.unref?.();
48
+ return timer;
49
+ }
50
+
51
+ export function registerDeferredConversation(conversationId: string): void {
52
+ buffers.set(conversationId, { state: "buffered", items: [] });
53
+ }
54
+
55
+ /**
56
+ * Buffer the signal when the originating conversation is armed, swallow it
57
+ * when tombstoned, otherwise return null so the caller emits normally.
58
+ */
59
+ export function bufferIfDeferred(
60
+ originatingConversationId: string | undefined,
61
+ params: EmitSignalParams<string>,
62
+ ): EmitSignalResult | null {
63
+ if (!originatingConversationId) return null;
64
+ const entry = buffers.get(originatingConversationId);
65
+ if (!entry) return null;
66
+ if (entry.state === "tombstoned") {
67
+ // Refresh the eviction timer so the tombstone outlives any continuing
68
+ // turn activity. Otherwise a long-running orphan `processMessage` could
69
+ // emit a `notifications send` after the fixed TTL elapsed and bypass
70
+ // buffering, recreating the "success + activity.failed" contradiction.
71
+ clearTimeout(entry.timer);
72
+ entry.timer = scheduleTombstoneEviction(originatingConversationId);
73
+ return {
74
+ signalId: uuid(),
75
+ deduplicated: false,
76
+ dispatched: false,
77
+ reason: "Notification dropped: background job did not complete",
78
+ deliveryResults: [],
79
+ };
80
+ }
81
+ entry.items.push(params);
82
+ return {
83
+ signalId: uuid(),
84
+ deduplicated: false,
85
+ dispatched: false,
86
+ reason: "Notification deferred until background job completes",
87
+ deliveryResults: [],
88
+ };
89
+ }
90
+
91
+ export async function commitDeferredConversation(
92
+ conversationId: string,
93
+ ): Promise<void> {
94
+ const entry = buffers.get(conversationId);
95
+ if (!entry || entry.state !== "buffered") return;
96
+ buffers.delete(conversationId);
97
+ for (const params of entry.items) {
98
+ try {
99
+ await emitNotificationSignal(params);
100
+ } catch (err) {
101
+ log.warn(
102
+ { err, conversationId },
103
+ "Buffered notification failed to emit on commit",
104
+ );
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Drop any buffered signals and tombstone the conversation. The tombstone
111
+ * persists until the turn has been quiet for `TOMBSTONE_TTL_MS`; each late
112
+ * notification observed via `bufferIfDeferred` refreshes the timer.
113
+ */
114
+ export function discardDeferredConversation(conversationId: string): number {
115
+ const entry = buffers.get(conversationId);
116
+ if (!entry) return 0;
117
+ const droppedCount = entry.state === "buffered" ? entry.items.length : 0;
118
+ if (entry.state === "tombstoned") clearTimeout(entry.timer);
119
+ buffers.set(conversationId, {
120
+ state: "tombstoned",
121
+ timer: scheduleTombstoneEviction(conversationId),
122
+ });
123
+ if (droppedCount > 0) {
124
+ log.info(
125
+ { conversationId, droppedCount },
126
+ "Discarded buffered notifications for failed background job",
127
+ );
128
+ }
129
+ return droppedCount;
130
+ }
131
+
132
+ /** @internal Test-only reset hook. */
133
+ export function resetDeferredForTest(): void {
134
+ buffers.clear();
135
+ }
@@ -388,10 +388,18 @@ export async function emitNotificationSignal<TEventName extends string>(
388
388
  // Step 5: Mirror background-origin signals into the home activity feed.
389
389
  // The helper itself decides whether to write (background filter); we
390
390
  // catch and log so a feed-write failure cannot poison the dispatch result.
391
+ // Pass the paired vellum delivery conversation as a fallback so producers
392
+ // whose `sourceContextId` is a sentinel string (e.g. heartbeat startup,
393
+ // credential health, watcher emits, scheduler retries-exhausted) still
394
+ // get a "Go to Convo" button — pointing at the conversation the
395
+ // broadcaster paired the notification with.
396
+ const pairedVellumConversationId = dispatchResult.deliveryResults.find(
397
+ (r) => r.channel === "vellum",
398
+ )?.conversationId;
391
399
  await writeHomeFeedItemForSignal(
392
400
  signal,
393
401
  decision,
394
- dispatchResult.deliveryResults,
402
+ pairedVellumConversationId,
395
403
  ).catch((err) => {
396
404
  log.warn({ err, signalId }, "writeHomeFeedItemForSignal threw");
397
405
  });