@vellumai/assistant 0.3.15 → 0.3.18

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 (306) hide show
  1. package/ARCHITECTURE.md +211 -12
  2. package/Dockerfile +1 -1
  3. package/README.md +11 -5
  4. package/docs/architecture/http-token-refresh.md +274 -0
  5. package/docs/architecture/memory.md +5 -4
  6. package/docs/architecture/scheduling.md +4 -88
  7. package/docs/runbook-trusted-contacts.md +283 -0
  8. package/docs/trusted-contact-access.md +247 -0
  9. package/package.json +1 -1
  10. package/scripts/ipc/check-swift-decoder-drift.ts +2 -0
  11. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2 -6
  12. package/src/__tests__/access-request-decision.test.ts +328 -0
  13. package/src/__tests__/asset-materialize-tool.test.ts +7 -7
  14. package/src/__tests__/asset-search-tool.test.ts +15 -15
  15. package/src/__tests__/attachments-store.test.ts +13 -13
  16. package/src/__tests__/call-controller.test.ts +150 -4
  17. package/src/__tests__/call-conversation-messages.test.ts +2 -2
  18. package/src/__tests__/call-pointer-messages.test.ts +28 -0
  19. package/src/__tests__/call-start-guardian-guard.test.ts +93 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +108 -12
  21. package/src/__tests__/channel-guardian.test.ts +19 -15
  22. package/src/__tests__/checker.test.ts +103 -48
  23. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
  24. package/src/__tests__/config-watcher.test.ts +356 -0
  25. package/src/__tests__/conversation-pairing.test.ts +127 -27
  26. package/src/__tests__/conversation-store.test.ts +36 -36
  27. package/src/__tests__/date-context.test.ts +179 -1
  28. package/src/__tests__/db-migration-rollback.test.ts +4 -7
  29. package/src/__tests__/deterministic-verification-control-plane.test.ts +5 -5
  30. package/src/__tests__/emit-signal-routing-intent.test.ts +179 -0
  31. package/src/__tests__/gateway-only-guard.test.ts +188 -0
  32. package/src/__tests__/guardian-action-conversation-turn.test.ts +451 -0
  33. package/src/__tests__/guardian-action-copy-generator.test.ts +197 -0
  34. package/src/__tests__/guardian-action-followup-executor.test.ts +379 -0
  35. package/src/__tests__/guardian-action-followup-store.test.ts +376 -0
  36. package/src/__tests__/guardian-action-late-reply.test.ts +425 -0
  37. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
  38. package/src/__tests__/guardian-action-store.test.ts +182 -0
  39. package/src/__tests__/guardian-action-sweep.test.ts +9 -9
  40. package/src/__tests__/guardian-dispatch.test.ts +120 -0
  41. package/src/__tests__/guardian-outbound-http.test.ts +194 -2
  42. package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
  43. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
  44. package/src/__tests__/handlers-telegram-config.test.ts +6 -6
  45. package/src/__tests__/hooks-runner.test.ts +13 -4
  46. package/src/__tests__/ingress-routes-http.test.ts +443 -0
  47. package/src/__tests__/intent-routing.test.ts +14 -0
  48. package/src/__tests__/ipc-snapshot.test.ts +23 -5
  49. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  50. package/src/__tests__/memory-regressions.test.ts +16 -12
  51. package/src/__tests__/non-member-access-request.test.ts +281 -0
  52. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  53. package/src/__tests__/notification-decision-strategy.test.ts +138 -1
  54. package/src/__tests__/notification-deep-link.test.ts +44 -1
  55. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  56. package/src/__tests__/notification-routing-intent.test.ts +11 -1
  57. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  58. package/src/__tests__/notification-thread-candidates.test.ts +166 -0
  59. package/src/__tests__/recording-intent.test.ts +1 -0
  60. package/src/__tests__/recording-state-machine.test.ts +328 -17
  61. package/src/__tests__/registry.test.ts +17 -8
  62. package/src/__tests__/relay-server.test.ts +105 -0
  63. package/src/__tests__/reminder.test.ts +13 -0
  64. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
  65. package/src/__tests__/scheduler-recurrence.test.ts +50 -0
  66. package/src/__tests__/server-history-render.test.ts +8 -8
  67. package/src/__tests__/session-agent-loop.test.ts +1 -0
  68. package/src/__tests__/session-runtime-assembly.test.ts +49 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -0
  70. package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
  71. package/src/__tests__/slack-channel-config.test.ts +230 -0
  72. package/src/__tests__/subagent-manager-notify.test.ts +4 -4
  73. package/src/__tests__/swarm-session-integration.test.ts +2 -2
  74. package/src/__tests__/system-prompt.test.ts +43 -0
  75. package/src/__tests__/task-management-tools.test.ts +3 -3
  76. package/src/__tests__/task-tools.test.ts +3 -3
  77. package/src/__tests__/trust-store.test.ts +38 -22
  78. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +489 -0
  79. package/src/__tests__/trusted-contact-multichannel.test.ts +405 -0
  80. package/src/__tests__/trusted-contact-verification.test.ts +360 -0
  81. package/src/__tests__/update-bulletin-format.test.ts +119 -0
  82. package/src/__tests__/update-bulletin-state.test.ts +129 -0
  83. package/src/__tests__/update-bulletin.test.ts +323 -0
  84. package/src/__tests__/update-template-contract.test.ts +24 -0
  85. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  86. package/src/agent/loop.ts +2 -2
  87. package/src/amazon/client.ts +2 -3
  88. package/src/calls/call-controller.ts +241 -39
  89. package/src/calls/call-conversation-messages.ts +2 -2
  90. package/src/calls/call-domain.ts +10 -3
  91. package/src/calls/call-pointer-messages.ts +17 -5
  92. package/src/calls/guardian-action-sweep.ts +77 -36
  93. package/src/calls/guardian-dispatch.ts +8 -0
  94. package/src/calls/relay-server.ts +51 -12
  95. package/src/calls/twilio-routes.ts +3 -1
  96. package/src/calls/types.ts +1 -1
  97. package/src/calls/voice-session-bridge.ts +8 -6
  98. package/src/cli/core-commands.ts +43 -3
  99. package/src/cli/map.ts +8 -5
  100. package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
  101. package/src/config/bundled-skills/tasks/SKILL.md +1 -1
  102. package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
  103. package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
  104. package/src/config/computer-use-prompt.ts +1 -0
  105. package/src/config/core-schema.ts +16 -0
  106. package/src/config/env-registry.ts +1 -0
  107. package/src/config/env.ts +16 -1
  108. package/src/config/memory-schema.ts +5 -0
  109. package/src/config/schema.ts +4 -0
  110. package/src/config/system-prompt.ts +69 -2
  111. package/src/config/templates/BOOTSTRAP.md +1 -1
  112. package/src/config/templates/IDENTITY.md +8 -4
  113. package/src/config/templates/SOUL.md +14 -0
  114. package/src/config/templates/UPDATES.md +15 -0
  115. package/src/config/templates/USER.md +5 -1
  116. package/src/config/types.ts +1 -0
  117. package/src/config/update-bulletin-format.ts +54 -0
  118. package/src/config/update-bulletin-state.ts +49 -0
  119. package/src/config/update-bulletin-template-path.ts +6 -0
  120. package/src/config/update-bulletin.ts +97 -0
  121. package/src/config/vellum-skills/catalog.json +6 -0
  122. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
  123. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
  124. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  125. package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
  126. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  127. package/src/context/window-manager.ts +43 -3
  128. package/src/daemon/config-watcher.ts +4 -2
  129. package/src/daemon/connection-policy.ts +21 -1
  130. package/src/daemon/daemon-control.ts +219 -8
  131. package/src/daemon/date-context.ts +174 -1
  132. package/src/daemon/guardian-action-generators.ts +175 -0
  133. package/src/daemon/guardian-verification-intent.ts +120 -0
  134. package/src/daemon/handlers/apps.ts +1 -3
  135. package/src/daemon/handlers/config-channels.ts +2 -2
  136. package/src/daemon/handlers/config-heartbeat.ts +1 -1
  137. package/src/daemon/handlers/config-inbox.ts +55 -159
  138. package/src/daemon/handlers/config-ingress.ts +1 -1
  139. package/src/daemon/handlers/config-integrations.ts +1 -1
  140. package/src/daemon/handlers/config-platform.ts +1 -1
  141. package/src/daemon/handlers/config-scheduling.ts +2 -2
  142. package/src/daemon/handlers/config-slack-channel.ts +190 -0
  143. package/src/daemon/handlers/config-telegram.ts +1 -1
  144. package/src/daemon/handlers/config-twilio.ts +1 -1
  145. package/src/daemon/handlers/config-voice.ts +100 -0
  146. package/src/daemon/handlers/config.ts +3 -0
  147. package/src/daemon/handlers/identity.ts +45 -25
  148. package/src/daemon/handlers/misc.ts +83 -5
  149. package/src/daemon/handlers/navigate-settings.ts +27 -0
  150. package/src/daemon/handlers/recording.ts +270 -144
  151. package/src/daemon/handlers/sessions.ts +100 -17
  152. package/src/daemon/handlers/subagents.ts +3 -3
  153. package/src/daemon/handlers/work-items.ts +10 -7
  154. package/src/daemon/ipc-contract/integrations.ts +9 -1
  155. package/src/daemon/ipc-contract/messages.ts +4 -0
  156. package/src/daemon/ipc-contract/sessions.ts +1 -1
  157. package/src/daemon/ipc-contract/settings.ts +26 -0
  158. package/src/daemon/ipc-contract/shared.ts +2 -0
  159. package/src/daemon/ipc-contract/work-items.ts +1 -7
  160. package/src/daemon/ipc-contract/workspace.ts +12 -1
  161. package/src/daemon/ipc-contract-inventory.json +6 -1
  162. package/src/daemon/ipc-contract.ts +5 -1
  163. package/src/daemon/lifecycle.ts +314 -266
  164. package/src/daemon/recording-intent.ts +0 -41
  165. package/src/daemon/response-tier.ts +2 -2
  166. package/src/daemon/server.ts +31 -9
  167. package/src/daemon/session-agent-loop-handlers.ts +34 -9
  168. package/src/daemon/session-agent-loop.ts +15 -8
  169. package/src/daemon/session-history.ts +3 -2
  170. package/src/daemon/session-media-retry.ts +3 -0
  171. package/src/daemon/session-messaging.ts +38 -4
  172. package/src/daemon/session-notifiers.ts +2 -2
  173. package/src/daemon/session-process.ts +546 -59
  174. package/src/daemon/session-queue-manager.ts +2 -0
  175. package/src/daemon/session-runtime-assembly.ts +39 -0
  176. package/src/daemon/session-skill-tools.ts +13 -4
  177. package/src/daemon/session-tool-setup.ts +5 -6
  178. package/src/daemon/session.ts +19 -8
  179. package/src/daemon/tls-certs.ts +60 -13
  180. package/src/daemon/tool-side-effects.ts +13 -5
  181. package/src/gallery/default-gallery.ts +32 -9
  182. package/src/influencer/client.ts +2 -1
  183. package/src/memory/channel-delivery-store.ts +35 -567
  184. package/src/memory/channel-guardian-store.ts +63 -1317
  185. package/src/memory/conflict-store.ts +4 -4
  186. package/src/memory/conversation-attention-store.ts +0 -3
  187. package/src/memory/conversation-crud.ts +668 -0
  188. package/src/memory/conversation-queries.ts +361 -0
  189. package/src/memory/conversation-store.ts +44 -983
  190. package/src/memory/db-connection.ts +3 -0
  191. package/src/memory/db-init.ts +33 -0
  192. package/src/memory/delivery-channels.ts +175 -0
  193. package/src/memory/delivery-crud.ts +211 -0
  194. package/src/memory/delivery-status.ts +199 -0
  195. package/src/memory/embedding-backend.ts +70 -4
  196. package/src/memory/embedding-local.ts +12 -2
  197. package/src/memory/entity-extractor.ts +3 -8
  198. package/src/memory/fts-reconciler.ts +136 -0
  199. package/src/memory/guardian-action-store.ts +418 -5
  200. package/src/memory/guardian-approvals.ts +569 -0
  201. package/src/memory/guardian-bindings.ts +130 -0
  202. package/src/memory/guardian-rate-limits.ts +196 -0
  203. package/src/memory/guardian-verification.ts +521 -0
  204. package/src/memory/job-handlers/index-maintenance.ts +2 -1
  205. package/src/memory/job-utils.ts +8 -5
  206. package/src/memory/jobs-store.ts +66 -6
  207. package/src/memory/jobs-worker.ts +23 -1
  208. package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
  209. package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
  210. package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
  211. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  212. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  213. package/src/memory/migrations/100-core-tables.ts +1 -1
  214. package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
  215. package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
  216. package/src/memory/migrations/112-assistant-inbox.ts +1 -1
  217. package/src/memory/migrations/113-late-migrations.ts +1 -1
  218. package/src/memory/migrations/116-messages-fts.ts +13 -0
  219. package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
  220. package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
  221. package/src/memory/migrations/index.ts +10 -3
  222. package/src/memory/migrations/validate-migration-state.ts +114 -15
  223. package/src/memory/qdrant-circuit-breaker.ts +105 -0
  224. package/src/memory/retriever.ts +46 -13
  225. package/src/memory/schema-migration.ts +4 -0
  226. package/src/memory/schema.ts +31 -8
  227. package/src/memory/search/semantic.ts +8 -90
  228. package/src/notifications/README.md +159 -18
  229. package/src/notifications/broadcaster.ts +69 -33
  230. package/src/notifications/conversation-pairing.ts +99 -21
  231. package/src/notifications/decision-engine.ts +176 -8
  232. package/src/notifications/deliveries-store.ts +39 -8
  233. package/src/notifications/emit-signal.ts +1 -0
  234. package/src/notifications/preferences-store.ts +7 -7
  235. package/src/notifications/thread-candidates.ts +269 -0
  236. package/src/notifications/types.ts +19 -0
  237. package/src/permissions/checker.ts +1 -16
  238. package/src/permissions/defaults.ts +25 -5
  239. package/src/permissions/prompter.ts +17 -0
  240. package/src/permissions/trust-store.ts +2 -0
  241. package/src/providers/failover.ts +19 -0
  242. package/src/providers/registry.ts +46 -1
  243. package/src/runtime/approval-message-composer.ts +1 -1
  244. package/src/runtime/channel-guardian-service.ts +15 -3
  245. package/src/runtime/channel-retry-sweep.ts +7 -2
  246. package/src/runtime/guardian-action-conversation-turn.ts +85 -0
  247. package/src/runtime/guardian-action-followup-executor.ts +301 -0
  248. package/src/runtime/guardian-action-message-composer.ts +245 -0
  249. package/src/runtime/guardian-outbound-actions.ts +26 -6
  250. package/src/runtime/guardian-verification-templates.ts +15 -9
  251. package/src/runtime/http-errors.ts +93 -0
  252. package/src/runtime/http-server.ts +133 -44
  253. package/src/runtime/http-types.ts +53 -0
  254. package/src/runtime/ingress-service.ts +237 -0
  255. package/src/runtime/middleware/error-handler.ts +4 -3
  256. package/src/runtime/middleware/rate-limiter.ts +160 -0
  257. package/src/runtime/middleware/request-logger.ts +71 -0
  258. package/src/runtime/middleware/twilio-validation.ts +7 -6
  259. package/src/runtime/pending-interactions.ts +12 -0
  260. package/src/runtime/routes/access-request-decision.ts +215 -0
  261. package/src/runtime/routes/app-routes.ts +25 -18
  262. package/src/runtime/routes/approval-routes.ts +18 -47
  263. package/src/runtime/routes/attachment-routes.ts +15 -41
  264. package/src/runtime/routes/call-routes.ts +20 -20
  265. package/src/runtime/routes/channel-delivery-routes.ts +6 -5
  266. package/src/runtime/routes/contact-routes.ts +4 -9
  267. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  268. package/src/runtime/routes/conversation-routes.ts +26 -57
  269. package/src/runtime/routes/debug-routes.ts +71 -0
  270. package/src/runtime/routes/events-routes.ts +3 -2
  271. package/src/runtime/routes/guardian-approval-interception.ts +221 -0
  272. package/src/runtime/routes/identity-routes.ts +14 -10
  273. package/src/runtime/routes/inbound-conversation.ts +3 -2
  274. package/src/runtime/routes/inbound-message-handler.ts +527 -62
  275. package/src/runtime/routes/ingress-routes.ts +174 -0
  276. package/src/runtime/routes/integration-routes.ts +78 -16
  277. package/src/runtime/routes/pairing-routes.ts +11 -10
  278. package/src/runtime/routes/secret-routes.ts +10 -18
  279. package/src/runtime/verification-rate-limiter.ts +83 -0
  280. package/src/schedule/schedule-store.ts +13 -1
  281. package/src/schedule/scheduler.ts +1 -1
  282. package/src/security/secret-ingress.ts +5 -2
  283. package/src/security/secret-scanner.ts +72 -6
  284. package/src/subagent/manager.ts +6 -4
  285. package/src/swarm/plan-validator.ts +4 -1
  286. package/src/tasks/task-runner.ts +3 -1
  287. package/src/tools/browser/api-map.ts +9 -6
  288. package/src/tools/calls/call-start.ts +20 -0
  289. package/src/tools/executor.ts +50 -568
  290. package/src/tools/permission-checker.ts +271 -0
  291. package/src/tools/registry.ts +14 -6
  292. package/src/tools/reminder/reminder-store.ts +7 -7
  293. package/src/tools/reminder/reminder.ts +6 -3
  294. package/src/tools/secret-detection-handler.ts +301 -0
  295. package/src/tools/subagent/message.ts +1 -1
  296. package/src/tools/system/voice-config.ts +62 -0
  297. package/src/tools/tasks/index.ts +3 -3
  298. package/src/tools/tasks/work-item-list.ts +3 -3
  299. package/src/tools/tasks/work-item-update.ts +4 -5
  300. package/src/tools/tool-approval-handler.ts +192 -0
  301. package/src/tools/tool-manifest.ts +2 -0
  302. package/src/version.ts +29 -2
  303. package/src/watcher/watcher-store.ts +9 -9
  304. package/src/work-items/work-item-runner.ts +9 -6
  305. /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
  306. /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
@@ -727,6 +727,10 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
727
727
  type: 'heartbeat_checklist_write',
728
728
  content: '- [ ] Check email\n- [ ] Review PRs',
729
729
  },
730
+ voice_config_update: {
731
+ type: 'voice_config_update',
732
+ activationKey: 'fn',
733
+ },
730
734
  };
731
735
 
732
736
  // ---------------------------------------------------------------------------
@@ -839,8 +843,8 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
839
843
  session_list_response: {
840
844
  type: 'session_list_response',
841
845
  sessions: [
842
- { id: 'sess-001', title: 'First session', updatedAt: 1700000000, threadType: 'standard' },
843
- { id: 'sess-002', title: 'Second session', updatedAt: 1700001000, threadType: 'standard', assistantAttention: { hasUnseenLatestAssistantMessage: true, latestAssistantMessageAt: 1700001000, lastSeenConfidence: 'explicit', lastSeenSignalType: 'macos_notification_view' } },
846
+ { id: 'sess-001', title: 'First session', createdAt: 1699999000, updatedAt: 1700000000, threadType: 'standard' },
847
+ { id: 'sess-002', title: 'Second session', createdAt: 1700000000, updatedAt: 1700001000, threadType: 'standard', assistantAttention: { hasUnseenLatestAssistantMessage: true, latestAssistantMessageAt: 1700001000, lastSeenConfidence: 'explicit', lastSeenSignalType: 'macos_notification_view' } },
844
848
  ],
845
849
  },
846
850
  sessions_clear_response: {
@@ -1775,9 +1779,6 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1775
1779
  tasks_changed: {
1776
1780
  type: 'tasks_changed',
1777
1781
  },
1778
- open_tasks_window: {
1779
- type: 'open_tasks_window',
1780
- },
1781
1782
  task_run_thread_created: {
1782
1783
  type: 'task_run_thread_created',
1783
1784
  conversationId: 'conv-task-run-001',
@@ -2001,6 +2002,23 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
2001
2002
  type: 'heartbeat_checklist_write_response',
2002
2003
  success: true,
2003
2004
  },
2005
+ navigate_settings: {
2006
+ type: 'navigate_settings',
2007
+ tab: 'general',
2008
+ },
2009
+ client_settings_update: {
2010
+ type: 'client_settings_update',
2011
+ key: 'activationKey',
2012
+ value: 'fn',
2013
+ },
2014
+ identity_changed: {
2015
+ type: 'identity_changed',
2016
+ name: 'Vellum',
2017
+ role: 'assistant',
2018
+ personality: 'friendly',
2019
+ emoji: '',
2020
+ home: '',
2021
+ },
2004
2022
  };
2005
2023
 
2006
2024
  // ---------------------------------------------------------------------------
@@ -188,7 +188,7 @@ describe('Story E2E: selfie yesterday -> generated image today', () => {
188
188
  let selfieId: string;
189
189
  let selfieAttachment: ReturnType<typeof uploadAttachment>;
190
190
 
191
- beforeEach(() => {
191
+ beforeEach(async () => {
192
192
  resetTables();
193
193
  // Clear sandbox so stale files from prior tests don't mask regressions
194
194
  rmSync(sandboxDir, { recursive: true, force: true });
@@ -216,7 +216,7 @@ describe('Story E2E: selfie yesterday -> generated image today', () => {
216
216
  selfieAttachment = uploadAttachment('selfie.png', 'image/png', TINY_PNG_BASE64);
217
217
  selfieId = selfieAttachment.id;
218
218
 
219
- const msgA = addMessage(threadA.id, 'user', 'Here is my selfie from yesterday');
219
+ const msgA = await addMessage(threadA.id, 'user', 'Here is my selfie from yesterday');
220
220
  linkAttachmentToMessage(msgA.id, selfieId, 0);
221
221
 
222
222
  // Backdate the selfie to "yesterday" for realism
@@ -365,7 +365,7 @@ describe('Story E2E: selfie yesterday -> generated image today', () => {
365
365
  generatedImageBase64,
366
366
  );
367
367
 
368
- const msgB = addMessage(threadB.id, 'assistant', 'Here is your generated portrait!');
368
+ const msgB = await addMessage(threadB.id, 'assistant', 'Here is your generated portrait!');
369
369
  linkAttachmentToMessage(msgB.id, outputAttachment.id, 0);
370
370
 
371
371
  // Verify the output attachment exists in the DB via raw search
@@ -475,7 +475,7 @@ describe('Private-thread variant: cross-thread media blocking', () => {
475
475
  // Upload selfie in a private thread
476
476
  const privateThread = createConversation({ title: 'Private selfie thread', threadType: 'private' });
477
477
  const selfie = uploadAttachment('private-selfie.png', 'image/png', TINY_PNG_BASE64);
478
- const msg = addMessage(privateThread.id, 'user', 'My private selfie');
478
+ const msg = await addMessage(privateThread.id, 'user', 'My private selfie');
479
479
  linkAttachmentToMessage(msg.id, selfie.id, 0);
480
480
 
481
481
  // Search from a standard thread
@@ -499,7 +499,7 @@ describe('Private-thread variant: cross-thread media blocking', () => {
499
499
  const privateThread = createConversation({ title: 'Private selfie thread', threadType: 'private' });
500
500
  const base64 = Buffer.from('private image data').toString('base64');
501
501
  const selfie = uploadAttachment('private-selfie.png', 'image/png', base64);
502
- const msg = addMessage(privateThread.id, 'user', 'My private selfie');
502
+ const msg = await addMessage(privateThread.id, 'user', 'My private selfie');
503
503
  linkAttachmentToMessage(msg.id, selfie.id, 0);
504
504
 
505
505
  // Try to materialize from a standard thread
@@ -523,7 +523,7 @@ describe('Private-thread variant: cross-thread media blocking', () => {
523
523
  test('selfie in private thread IS accessible from the same private thread', async () => {
524
524
  const privateThread = createConversation({ title: 'Private selfie thread', threadType: 'private' });
525
525
  const selfie = uploadAttachment('private-selfie.png', 'image/png', TINY_PNG_BASE64);
526
- const msg = addMessage(privateThread.id, 'user', 'My private selfie');
526
+ const msg = await addMessage(privateThread.id, 'user', 'My private selfie');
527
527
  linkAttachmentToMessage(msg.id, selfie.id, 0);
528
528
 
529
529
  // Search from the same private thread
@@ -552,7 +552,7 @@ describe('Private-thread variant: cross-thread media blocking', () => {
552
552
  test('selfie in private thread A is NOT accessible from private thread B', async () => {
553
553
  const privateThreadA = createConversation({ title: 'Private thread A', threadType: 'private' });
554
554
  const selfie = uploadAttachment('thread-a-selfie.png', 'image/png', TINY_PNG_BASE64);
555
- const msgA = addMessage(privateThreadA.id, 'user', 'Selfie in thread A');
555
+ const msgA = await addMessage(privateThreadA.id, 'user', 'Selfie in thread A');
556
556
  linkAttachmentToMessage(msgA.id, selfie.id, 0);
557
557
 
558
558
  // Search from a different private thread
@@ -3577,11 +3577,11 @@ describe('Memory regressions', () => {
3577
3577
  });
3578
3578
 
3579
3579
  // PR-17: addMessage() passes conversation scope to the indexer
3580
- test('addMessage inherits private conversation scope on memory segments', () => {
3580
+ test('addMessage inherits private conversation scope on memory segments', async () => {
3581
3581
  const conv = createConversation({ title: 'Private thread', threadType: 'private' });
3582
3582
  expect(conv.memoryScopeId).toMatch(/^private:/);
3583
3583
 
3584
- const msg = addMessage(conv.id, 'user', 'My secret project details for the private thread.');
3584
+ const msg = await addMessage(conv.id, 'user', 'My secret project details for the private thread.');
3585
3585
 
3586
3586
  const db = getDb();
3587
3587
  const segments = db
@@ -3596,11 +3596,11 @@ describe('Memory regressions', () => {
3596
3596
  }
3597
3597
  });
3598
3598
 
3599
- test('addMessage uses default scope for standard conversations', () => {
3599
+ test('addMessage uses default scope for standard conversations', async () => {
3600
3600
  const conv = createConversation({ title: 'Standard thread', threadType: 'standard' });
3601
3601
  expect(conv.memoryScopeId).toBe('default');
3602
3602
 
3603
- const msg = addMessage(conv.id, 'user', 'Normal conversation content for testing scope defaults.');
3603
+ const msg = await addMessage(conv.id, 'user', 'Normal conversation content for testing scope defaults.');
3604
3604
 
3605
3605
  const db = getDb();
3606
3606
  const segments = db
@@ -3910,6 +3910,7 @@ describe('Memory regressions', () => {
3910
3910
  deferrals: 0,
3911
3911
  runAfter: 0,
3912
3912
  lastError: null,
3913
+ startedAt: Date.now(),
3913
3914
  createdAt: Date.now(),
3914
3915
  updatedAt: Date.now(),
3915
3916
  };
@@ -3958,7 +3959,7 @@ describe('Memory regressions', () => {
3958
3959
  payload: { conversationId: privConv.id },
3959
3960
  status: 'running' as const,
3960
3961
  attempts: 0, deferrals: 0, runAfter: 0, lastError: null,
3961
- createdAt: now, updatedAt: now,
3962
+ startedAt: now, createdAt: now, updatedAt: now,
3962
3963
  }, TEST_CONFIG);
3963
3964
 
3964
3965
  // Create a standard conversation and build its summary
@@ -3990,7 +3991,7 @@ describe('Memory regressions', () => {
3990
3991
  payload: { conversationId: stdConv.id },
3991
3992
  status: 'running' as const,
3992
3993
  attempts: 0, deferrals: 0, runAfter: 0, lastError: null,
3993
- createdAt: now, updatedAt: now,
3994
+ startedAt: now, createdAt: now, updatedAt: now,
3994
3995
  }, TEST_CONFIG);
3995
3996
 
3996
3997
  // Query summaries scoped to 'default' — should only include the standard one
@@ -4018,7 +4019,7 @@ describe('Memory regressions', () => {
4018
4019
  const privScope = getConversationMemoryScopeId(privConv.id);
4019
4020
  expect(privScope).toMatch(/^private:/);
4020
4021
 
4021
- const privMsg = addMessage(
4022
+ const privMsg = await addMessage(
4022
4023
  privConv.id,
4023
4024
  'user',
4024
4025
  'I prefer using the Zephyr framework for all backend microservices.',
@@ -4095,7 +4096,7 @@ describe('Memory regressions', () => {
4095
4096
  const stdScope = getConversationMemoryScopeId(stdConv.id);
4096
4097
  expect(stdScope).toBe('default');
4097
4098
 
4098
- const stdMsg = addMessage(
4099
+ const stdMsg = await addMessage(
4099
4100
  stdConv.id,
4100
4101
  'user',
4101
4102
  'I prefer using the Obsidian editor for all my note-taking workflows.',
@@ -4417,6 +4418,7 @@ describe('Memory regressions', () => {
4417
4418
  deferrals: 0,
4418
4419
  runAfter: 0,
4419
4420
  lastError: null,
4421
+ startedAt: Date.now(),
4420
4422
  createdAt: Date.now(),
4421
4423
  updatedAt: Date.now(),
4422
4424
  };
@@ -4461,6 +4463,7 @@ describe('Memory regressions', () => {
4461
4463
  deferrals: 0,
4462
4464
  runAfter: 0,
4463
4465
  lastError: null,
4466
+ startedAt: Date.now(),
4464
4467
  createdAt: Date.now(),
4465
4468
  updatedAt: Date.now(),
4466
4469
  };
@@ -4535,6 +4538,7 @@ describe('Memory regressions', () => {
4535
4538
  deferrals: 0,
4536
4539
  runAfter: 0,
4537
4540
  lastError: null,
4541
+ startedAt: Date.now(),
4538
4542
  createdAt: Date.now(),
4539
4543
  updatedAt: Date.now(),
4540
4544
  };
@@ -4558,7 +4562,7 @@ describe('Memory regressions', () => {
4558
4562
 
4559
4563
  // ── Provenance plumbing tests ────────────────────────────────────────
4560
4564
 
4561
- test('provenance fields are preserved in stored message metadata', () => {
4565
+ test('provenance fields are preserved in stored message metadata', async () => {
4562
4566
  const conv = createConversation('provenance-preserve');
4563
4567
  const metadata = {
4564
4568
  userMessageChannel: 'telegram' as const,
@@ -4567,7 +4571,7 @@ describe('Memory regressions', () => {
4567
4571
  provenanceGuardianExternalUserId: 'guardian-123',
4568
4572
  provenanceRequesterIdentifier: 'Alice',
4569
4573
  };
4570
- const msg = addMessage(conv.id, 'user', 'Hello from telegram', metadata);
4574
+ const msg = await addMessage(conv.id, 'user', 'Hello from telegram', metadata);
4571
4575
 
4572
4576
  const db = getDb();
4573
4577
  const stored = db
@@ -4628,7 +4632,7 @@ describe('Memory regressions', () => {
4628
4632
  expect(result.provenanceRequesterIdentifier).toBe('Charlie');
4629
4633
  });
4630
4634
 
4631
- test('indexMessageNow receives provenanceActorRole when metadata includes it', () => {
4635
+ test('indexMessageNow receives provenanceActorRole when metadata includes it', async () => {
4632
4636
  const conv = createConversation('provenance-indexer');
4633
4637
  const metadata = {
4634
4638
  provenanceActorRole: 'non-guardian' as const,
@@ -4636,7 +4640,7 @@ describe('Memory regressions', () => {
4636
4640
  };
4637
4641
  // addMessage parses metadata and passes provenanceActorRole to indexMessageNow.
4638
4642
  // We verify indirectly: the message is persisted with metadata and segments are indexed.
4639
- const msg = addMessage(conv.id, 'user', 'Test provenance indexing message with enough content to segment', metadata);
4643
+ const msg = await addMessage(conv.id, 'user', 'Test provenance indexing message with enough content to segment', metadata);
4640
4644
  expect(msg.id).toBeTruthy();
4641
4645
 
4642
4646
  // Verify segments were created (indexMessageNow was called successfully)
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Tests for the non-member access request notification flow.
3
+ *
4
+ * When a non-member messages the assistant on a channel, the system should:
5
+ * 1. Deny the message with the standard rejection reply
6
+ * 2. Notify the guardian (if a guardian binding exists)
7
+ * 3. Create a guardian approval request for the access request
8
+ * 4. Deduplicate: don't create duplicate requests for repeated messages
9
+ */
10
+ import { mkdtempSync, rmSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Test isolation: in-memory SQLite via temp directory
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const testDir = mkdtempSync(join(tmpdir(), 'non-member-access-request-test-'));
21
+
22
+ mock.module('../util/platform.js', () => ({
23
+ getRootDir: () => testDir,
24
+ getDataDir: () => testDir,
25
+ isMacOS: () => process.platform === 'darwin',
26
+ isLinux: () => process.platform === 'linux',
27
+ isWindows: () => process.platform === 'win32',
28
+ getSocketPath: () => join(testDir, 'test.sock'),
29
+ getPidPath: () => join(testDir, 'test.pid'),
30
+ getDbPath: () => join(testDir, 'test.db'),
31
+ getLogPath: () => join(testDir, 'test.log'),
32
+ ensureDataDir: () => {},
33
+ normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
34
+ readHttpToken: () => 'test-bearer-token',
35
+ }));
36
+
37
+ mock.module('../util/logger.js', () => ({
38
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
39
+ get: () => () => {},
40
+ }),
41
+ }));
42
+
43
+ // Mock security check to always pass
44
+ mock.module('../security/secret-ingress.js', () => ({
45
+ checkIngressForSecrets: () => ({ blocked: false }),
46
+ }));
47
+
48
+ // Mock ingress member store: findMember always returns null (non-member),
49
+ // updateLastSeen is a no-op.
50
+ mock.module('../memory/ingress-member-store.js', () => ({
51
+ findMember: () => null,
52
+ updateLastSeen: () => {},
53
+ upsertMember: () => {},
54
+ }));
55
+
56
+ mock.module('../config/env.js', () => ({
57
+ getGatewayInternalBaseUrl: () => 'http://127.0.0.1:7830',
58
+ }));
59
+
60
+ // Track emitNotificationSignal calls
61
+ const emitSignalCalls: Array<Record<string, unknown>> = [];
62
+ mock.module('../notifications/emit-signal.js', () => ({
63
+ emitNotificationSignal: async (params: Record<string, unknown>) => {
64
+ emitSignalCalls.push(params);
65
+ return {
66
+ signalId: 'mock-signal-id',
67
+ deduplicated: false,
68
+ dispatched: true,
69
+ reason: 'mock',
70
+ deliveryResults: [],
71
+ };
72
+ },
73
+ }));
74
+
75
+ // Track deliverChannelReply calls
76
+ const deliverReplyCalls: Array<{ url: string; payload: Record<string, unknown> }> = [];
77
+ mock.module('../runtime/gateway-client.js', () => ({
78
+ deliverChannelReply: async (url: string, payload: Record<string, unknown>) => {
79
+ deliverReplyCalls.push({ url, payload });
80
+ },
81
+ }));
82
+
83
+ import {
84
+ createBinding,
85
+ findPendingAccessRequestForRequester,
86
+ } from '../memory/channel-guardian-store.js';
87
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
88
+ import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
89
+
90
+ initializeDb();
91
+
92
+ afterAll(() => {
93
+ resetDb();
94
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
95
+ });
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Helpers
99
+ // ---------------------------------------------------------------------------
100
+
101
+ const TEST_BEARER_TOKEN = 'test-token';
102
+
103
+ function resetState(): void {
104
+ const db = getDb();
105
+ db.run('DELETE FROM channel_guardian_approval_requests');
106
+ db.run('DELETE FROM channel_guardian_bindings');
107
+ db.run('DELETE FROM channel_inbound_events');
108
+ db.run('DELETE FROM conversations');
109
+ db.run('DELETE FROM notification_events');
110
+ emitSignalCalls.length = 0;
111
+ deliverReplyCalls.length = 0;
112
+ }
113
+
114
+ function buildInboundRequest(overrides: Record<string, unknown> = {}): Request {
115
+ const body: Record<string, unknown> = {
116
+ sourceChannel: 'telegram',
117
+ interface: 'telegram',
118
+ externalChatId: 'chat-123',
119
+ externalMessageId: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
120
+ content: 'Hello, can I use this assistant?',
121
+ senderExternalUserId: 'user-unknown-456',
122
+ senderName: 'Alice Unknown',
123
+ senderUsername: 'alice_unknown',
124
+ replyCallbackUrl: 'http://localhost:7830/deliver/telegram',
125
+ ...overrides,
126
+ };
127
+
128
+ return new Request('http://localhost:8080/channels/inbound', {
129
+ method: 'POST',
130
+ headers: {
131
+ 'Content-Type': 'application/json',
132
+ 'X-Gateway-Origin': TEST_BEARER_TOKEN,
133
+ },
134
+ body: JSON.stringify(body),
135
+ });
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Tests
140
+ // ---------------------------------------------------------------------------
141
+
142
+ describe('non-member access request notification', () => {
143
+ beforeEach(() => {
144
+ resetState();
145
+ });
146
+
147
+ test('non-member message is denied with rejection reply', async () => {
148
+ const req = buildInboundRequest();
149
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
150
+ const json = await resp.json() as Record<string, unknown>;
151
+
152
+ expect(json.denied).toBe(true);
153
+ expect(json.reason).toBe('not_a_member');
154
+
155
+ // Rejection reply was delivered
156
+ expect(deliverReplyCalls.length).toBe(1);
157
+ expect((deliverReplyCalls[0].payload as Record<string, unknown>).text).toContain("you haven't been approved");
158
+ });
159
+
160
+ test('guardian is notified when a non-member messages and a guardian binding exists', async () => {
161
+ // Set up a guardian binding for this channel
162
+ createBinding({
163
+ assistantId: 'self',
164
+ channel: 'telegram',
165
+ guardianExternalUserId: 'guardian-user-789',
166
+ guardianDeliveryChatId: 'guardian-chat-789',
167
+ });
168
+
169
+ const req = buildInboundRequest();
170
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
171
+ const json = await resp.json() as Record<string, unknown>;
172
+
173
+ // Message is still denied
174
+ expect(json.denied).toBe(true);
175
+ expect(json.reason).toBe('not_a_member');
176
+
177
+ // Rejection reply was delivered
178
+ expect(deliverReplyCalls.length).toBe(1);
179
+
180
+ // A notification signal was emitted
181
+ expect(emitSignalCalls.length).toBe(1);
182
+ expect(emitSignalCalls[0].sourceEventName).toBe('ingress.access_request');
183
+ expect(emitSignalCalls[0].sourceChannel).toBe('telegram');
184
+ const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
185
+ expect(payload.senderExternalUserId).toBe('user-unknown-456');
186
+ expect(payload.senderName).toBe('Alice Unknown');
187
+
188
+ // An approval request was created
189
+ const pending = findPendingAccessRequestForRequester(
190
+ 'self',
191
+ 'telegram',
192
+ 'user-unknown-456',
193
+ 'ingress_access_request',
194
+ );
195
+ expect(pending).not.toBeNull();
196
+ expect(pending!.status).toBe('pending');
197
+ expect(pending!.requesterExternalUserId).toBe('user-unknown-456');
198
+ expect(pending!.guardianExternalUserId).toBe('guardian-user-789');
199
+ expect(pending!.toolName).toBe('ingress_access_request');
200
+ });
201
+
202
+ test('no duplicate approval requests for repeated messages from same non-member', async () => {
203
+ createBinding({
204
+ assistantId: 'self',
205
+ channel: 'telegram',
206
+ guardianExternalUserId: 'guardian-user-789',
207
+ guardianDeliveryChatId: 'guardian-chat-789',
208
+ });
209
+
210
+ // First message
211
+ const req1 = buildInboundRequest();
212
+ await handleChannelInbound(req1, undefined, TEST_BEARER_TOKEN);
213
+
214
+ // Second message from the same user
215
+ const req2 = buildInboundRequest({
216
+ externalMessageId: `msg-second-${Date.now()}`,
217
+ content: 'Please let me in!',
218
+ });
219
+ await handleChannelInbound(req2, undefined, TEST_BEARER_TOKEN);
220
+
221
+ // Both messages should be denied with rejection replies
222
+ expect(deliverReplyCalls.length).toBe(2);
223
+
224
+ // Only one notification signal should be emitted (second is deduplicated)
225
+ expect(emitSignalCalls.length).toBe(1);
226
+
227
+ // Only one approval request should exist
228
+ const pending = findPendingAccessRequestForRequester(
229
+ 'self',
230
+ 'telegram',
231
+ 'user-unknown-456',
232
+ 'ingress_access_request',
233
+ );
234
+ expect(pending).not.toBeNull();
235
+ });
236
+
237
+ test('deny works without error when no guardian binding exists', async () => {
238
+ // No guardian binding — should deny without notification
239
+ const req = buildInboundRequest();
240
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
241
+ const json = await resp.json() as Record<string, unknown>;
242
+
243
+ expect(json.denied).toBe(true);
244
+ expect(json.reason).toBe('not_a_member');
245
+
246
+ // Rejection reply was still delivered
247
+ expect(deliverReplyCalls.length).toBe(1);
248
+
249
+ // No notification signal was emitted
250
+ expect(emitSignalCalls.length).toBe(0);
251
+
252
+ // No approval request was created
253
+ const pending = findPendingAccessRequestForRequester(
254
+ 'self',
255
+ 'telegram',
256
+ 'user-unknown-456',
257
+ 'ingress_access_request',
258
+ );
259
+ expect(pending).toBeNull();
260
+ });
261
+
262
+ test('no notification when senderExternalUserId is absent', async () => {
263
+ createBinding({
264
+ assistantId: 'self',
265
+ channel: 'telegram',
266
+ guardianExternalUserId: 'guardian-user-789',
267
+ guardianDeliveryChatId: 'guardian-chat-789',
268
+ });
269
+
270
+ // Message without senderExternalUserId — can't identify the requester.
271
+ // The ACL check requires senderExternalUserId to look up members,
272
+ // so without it the non-member gate is bypassed entirely.
273
+ const req = buildInboundRequest({
274
+ senderExternalUserId: undefined,
275
+ });
276
+ await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
277
+
278
+ // No access request notification should fire (no identity to notify about)
279
+ expect(emitSignalCalls.length).toBe(0);
280
+ });
281
+ });
@@ -6,6 +6,8 @@
6
6
  * - Handles missing adapters gracefully
7
7
  * - Falls back to copy-composer when decision copy is missing
8
8
  * - Reports delivery results per channel
9
+ * - Emits notification_thread_created only when a new conversation is created
10
+ * - Does NOT emit notification_thread_created when reusing an existing thread
9
11
  */
10
12
 
11
13
  import { describe, expect, mock, test } from 'bun:test';
@@ -36,6 +38,32 @@ mock.module('../notifications/deliveries-store.js', () => ({
36
38
  updateDeliveryStatus: () => {},
37
39
  }));
38
40
 
41
+ // Configurable mock for conversation-pairing.
42
+ // By default returns a "new conversation" result with a stable UUID.
43
+ // Set `nextPairingResult` to override the return value for a single call.
44
+ let nextPairingResult: import('../notifications/conversation-pairing.js').PairingResult | null = null;
45
+ let pairingCallCount = 0;
46
+
47
+ mock.module('../notifications/conversation-pairing.js', () => ({
48
+ pairDeliveryWithConversation: async () => {
49
+ if (nextPairingResult) {
50
+ const result = nextPairingResult;
51
+ nextPairingResult = null;
52
+ return result;
53
+ }
54
+ // Default: simulate creating a new conversation with a unique ID
55
+ const id = `mock-conv-${++pairingCallCount}`;
56
+ return {
57
+ conversationId: id,
58
+ messageId: `mock-msg-${pairingCallCount}`,
59
+ strategy: 'start_new_conversation' as const,
60
+ createdNewConversation: true,
61
+ threadDecisionFallbackUsed: false,
62
+ };
63
+ },
64
+ }));
65
+
66
+ import type { ThreadCreatedInfo } from '../notifications/broadcaster.js';
39
67
  import { NotificationBroadcaster } from '../notifications/broadcaster.js';
40
68
  import type { NotificationSignal } from '../notifications/signal.js';
41
69
  import type {
@@ -167,10 +195,17 @@ describe('notification broadcaster', () => {
167
195
  await broadcaster.broadcastDecision(signal, decision);
168
196
 
169
197
  expect(vellumAdapter.sent).toHaveLength(1);
170
- expect(vellumAdapter.sent[0].deepLinkTarget).toEqual({
171
- conversationId: 'conv-123',
172
- screen: 'thread',
173
- });
198
+ // The broadcaster overwrites deepLinkTarget.conversationId with the
199
+ // paired conversation ID, so the original 'conv-123' is replaced.
200
+ // Verify the structure is correct and that conversationId comes from
201
+ // the pairing result, not the pre-pairing placeholder.
202
+ const deepLink = vellumAdapter.sent[0].deepLinkTarget;
203
+ expect(deepLink).toBeDefined();
204
+ expect(deepLink!.screen).toBe('thread');
205
+ expect(deepLink!.conversationId).toBeDefined();
206
+ expect(deepLink!.conversationId).not.toBe('conv-123');
207
+ // Should be the paired conversation ID from conversation-pairing
208
+ expect(deepLink!.conversationId).toMatch(/^mock-conv-\d+$/);
174
209
  });
175
210
 
176
211
  test('multiple channels receive independent copy from the decision', async () => {
@@ -253,4 +288,80 @@ describe('notification broadcaster', () => {
253
288
  expect(results).toHaveLength(0);
254
289
  expect(vellumAdapter.sent).toHaveLength(0);
255
290
  });
291
+
292
+ // ── Thread-created IPC emission ─────────────────────────────────────
293
+
294
+ test('fires onThreadCreated when a new vellum conversation is created (start_new)', async () => {
295
+ const vellumAdapter = new MockAdapter('vellum');
296
+ const broadcaster = new NotificationBroadcaster([vellumAdapter]);
297
+ const threadCreatedCalls: ThreadCreatedInfo[] = [];
298
+ broadcaster.setOnThreadCreated((info) => threadCreatedCalls.push(info));
299
+
300
+ const signal = makeSignal();
301
+ // No threadActions means default start_new behavior
302
+ const decision = makeDecision();
303
+
304
+ await broadcaster.broadcastDecision(signal, decision);
305
+
306
+ // Pairing creates a new conversation by default, so onThreadCreated should fire
307
+ expect(threadCreatedCalls).toHaveLength(1);
308
+ expect(threadCreatedCalls[0].sourceEventName).toBe('test.event');
309
+ });
310
+
311
+ test('fires per-dispatch onThreadCreated callback on new conversation', async () => {
312
+ const vellumAdapter = new MockAdapter('vellum');
313
+ const broadcaster = new NotificationBroadcaster([vellumAdapter]);
314
+ const dispatchCalls: ThreadCreatedInfo[] = [];
315
+
316
+ const signal = makeSignal();
317
+ const decision = makeDecision();
318
+
319
+ await broadcaster.broadcastDecision(signal, decision, {
320
+ onThreadCreated: (info) => dispatchCalls.push(info),
321
+ });
322
+
323
+ expect(dispatchCalls).toHaveLength(1);
324
+ });
325
+
326
+ test('does NOT fire class-level onThreadCreated when reusing an existing thread', async () => {
327
+ const vellumAdapter = new MockAdapter('vellum');
328
+ const broadcaster = new NotificationBroadcaster([vellumAdapter]);
329
+ const ipcCalls: ThreadCreatedInfo[] = [];
330
+ const dispatchCalls: ThreadCreatedInfo[] = [];
331
+ broadcaster.setOnThreadCreated((info) => ipcCalls.push(info));
332
+
333
+ // Simulate a successful reuse by injecting a pairing result with
334
+ // createdNewConversation=false. This bypasses the real conversation
335
+ // store (which would fall back to creating a new conversation since
336
+ // the target does not exist in the test DB).
337
+ nextPairingResult = {
338
+ conversationId: 'conv-reused-456',
339
+ messageId: 'msg-reused-789',
340
+ strategy: 'start_new_conversation',
341
+ createdNewConversation: false,
342
+ threadDecisionFallbackUsed: false,
343
+ };
344
+
345
+ const signal = makeSignal();
346
+ const decision = makeDecision({
347
+ threadActions: {
348
+ vellum: { action: 'reuse_existing', conversationId: 'conv-existing-123' },
349
+ },
350
+ });
351
+
352
+ await broadcaster.broadcastDecision(signal, decision, {
353
+ onThreadCreated: (info) => dispatchCalls.push(info),
354
+ });
355
+
356
+ // The class-level IPC callback should NOT fire because
357
+ // createdNewConversation is false — the client already knows about
358
+ // the reused conversation.
359
+ expect(ipcCalls).toHaveLength(0);
360
+
361
+ // The per-dispatch callback SHOULD fire for both new and reused
362
+ // pairings (used by callers like dispatchGuardianQuestion for
363
+ // delivery bookkeeping).
364
+ expect(dispatchCalls).toHaveLength(1);
365
+ expect(dispatchCalls[0].conversationId).toBe('conv-reused-456');
366
+ });
256
367
  });