@vellumai/assistant 0.3.15 → 0.3.16

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 (290) hide show
  1. package/ARCHITECTURE.md +142 -0
  2. package/Dockerfile +1 -1
  3. package/README.md +5 -5
  4. package/docs/architecture/http-token-refresh.md +252 -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 +331 -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 +16 -14
  22. package/src/__tests__/checker.test.ts +24 -0
  23. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
  24. package/src/__tests__/config-watcher.test.ts +358 -0
  25. package/src/__tests__/conversation-pairing.test.ts +24 -24
  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 +294 -0
  37. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
  38. package/src/__tests__/guardian-action-sweep.test.ts +9 -9
  39. package/src/__tests__/guardian-outbound-http.test.ts +194 -2
  40. package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
  41. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
  42. package/src/__tests__/handlers-telegram-config.test.ts +6 -6
  43. package/src/__tests__/hooks-runner.test.ts +13 -4
  44. package/src/__tests__/ingress-routes-http.test.ts +443 -0
  45. package/src/__tests__/intent-routing.test.ts +14 -0
  46. package/src/__tests__/ipc-snapshot.test.ts +2 -5
  47. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  48. package/src/__tests__/memory-regressions.test.ts +16 -12
  49. package/src/__tests__/non-member-access-request.test.ts +282 -0
  50. package/src/__tests__/notification-decision-strategy.test.ts +136 -0
  51. package/src/__tests__/notification-routing-intent.test.ts +11 -1
  52. package/src/__tests__/notification-thread-candidates.test.ts +166 -0
  53. package/src/__tests__/recording-intent.test.ts +1 -0
  54. package/src/__tests__/recording-state-machine.test.ts +328 -17
  55. package/src/__tests__/registry.test.ts +17 -8
  56. package/src/__tests__/relay-server.test.ts +105 -0
  57. package/src/__tests__/reminder.test.ts +13 -0
  58. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
  59. package/src/__tests__/scheduler-recurrence.test.ts +50 -0
  60. package/src/__tests__/server-history-render.test.ts +8 -8
  61. package/src/__tests__/session-agent-loop.test.ts +1 -0
  62. package/src/__tests__/session-runtime-assembly.test.ts +49 -0
  63. package/src/__tests__/session-skill-tools.test.ts +1 -0
  64. package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
  65. package/src/__tests__/slack-channel-config.test.ts +230 -0
  66. package/src/__tests__/subagent-manager-notify.test.ts +4 -4
  67. package/src/__tests__/swarm-session-integration.test.ts +2 -2
  68. package/src/__tests__/system-prompt.test.ts +43 -0
  69. package/src/__tests__/task-management-tools.test.ts +3 -3
  70. package/src/__tests__/task-tools.test.ts +3 -3
  71. package/src/__tests__/trust-store.test.ts +17 -1
  72. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +491 -0
  73. package/src/__tests__/trusted-contact-multichannel.test.ts +409 -0
  74. package/src/__tests__/trusted-contact-verification.test.ts +360 -0
  75. package/src/__tests__/update-bulletin-format.test.ts +119 -0
  76. package/src/__tests__/update-bulletin-state.test.ts +129 -0
  77. package/src/__tests__/update-bulletin.test.ts +260 -0
  78. package/src/__tests__/update-template-contract.test.ts +29 -0
  79. package/src/agent/loop.ts +2 -2
  80. package/src/amazon/client.ts +2 -3
  81. package/src/calls/call-controller.ts +115 -34
  82. package/src/calls/call-conversation-messages.ts +2 -2
  83. package/src/calls/call-domain.ts +10 -3
  84. package/src/calls/call-pointer-messages.ts +17 -5
  85. package/src/calls/guardian-action-sweep.ts +77 -36
  86. package/src/calls/relay-server.ts +51 -12
  87. package/src/calls/twilio-routes.ts +3 -1
  88. package/src/calls/types.ts +1 -1
  89. package/src/calls/voice-session-bridge.ts +4 -4
  90. package/src/cli/core-commands.ts +3 -3
  91. package/src/cli/map.ts +8 -5
  92. package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
  93. package/src/config/bundled-skills/tasks/SKILL.md +1 -1
  94. package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
  95. package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
  96. package/src/config/computer-use-prompt.ts +1 -0
  97. package/src/config/core-schema.ts +16 -0
  98. package/src/config/env-registry.ts +1 -0
  99. package/src/config/env.ts +16 -1
  100. package/src/config/memory-schema.ts +5 -0
  101. package/src/config/schema.ts +4 -0
  102. package/src/config/system-prompt.ts +69 -2
  103. package/src/config/templates/BOOTSTRAP.md +1 -1
  104. package/src/config/templates/IDENTITY.md +8 -4
  105. package/src/config/templates/SOUL.md +14 -0
  106. package/src/config/templates/UPDATES.md +16 -0
  107. package/src/config/templates/USER.md +5 -1
  108. package/src/config/types.ts +1 -0
  109. package/src/config/update-bulletin-format.ts +52 -0
  110. package/src/config/update-bulletin-state.ts +49 -0
  111. package/src/config/update-bulletin.ts +82 -0
  112. package/src/config/vellum-skills/catalog.json +6 -0
  113. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
  114. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
  115. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  116. package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
  117. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  118. package/src/context/window-manager.ts +43 -3
  119. package/src/daemon/config-watcher.ts +1 -0
  120. package/src/daemon/connection-policy.ts +21 -1
  121. package/src/daemon/daemon-control.ts +164 -7
  122. package/src/daemon/date-context.ts +174 -1
  123. package/src/daemon/guardian-action-generators.ts +175 -0
  124. package/src/daemon/guardian-verification-intent.ts +120 -0
  125. package/src/daemon/handlers/apps.ts +1 -3
  126. package/src/daemon/handlers/config-channels.ts +2 -2
  127. package/src/daemon/handlers/config-heartbeat.ts +1 -1
  128. package/src/daemon/handlers/config-inbox.ts +55 -159
  129. package/src/daemon/handlers/config-ingress.ts +1 -1
  130. package/src/daemon/handlers/config-integrations.ts +1 -1
  131. package/src/daemon/handlers/config-platform.ts +1 -1
  132. package/src/daemon/handlers/config-scheduling.ts +2 -2
  133. package/src/daemon/handlers/config-slack-channel.ts +190 -0
  134. package/src/daemon/handlers/config-telegram.ts +1 -1
  135. package/src/daemon/handlers/config-twilio.ts +1 -1
  136. package/src/daemon/handlers/config-voice.ts +100 -0
  137. package/src/daemon/handlers/config.ts +3 -0
  138. package/src/daemon/handlers/misc.ts +83 -5
  139. package/src/daemon/handlers/navigate-settings.ts +27 -0
  140. package/src/daemon/handlers/recording.ts +270 -144
  141. package/src/daemon/handlers/sessions.ts +100 -17
  142. package/src/daemon/handlers/subagents.ts +3 -3
  143. package/src/daemon/handlers/work-items.ts +10 -7
  144. package/src/daemon/ipc-contract/integrations.ts +9 -1
  145. package/src/daemon/ipc-contract/messages.ts +4 -0
  146. package/src/daemon/ipc-contract/sessions.ts +1 -1
  147. package/src/daemon/ipc-contract/settings.ts +26 -0
  148. package/src/daemon/ipc-contract/shared.ts +2 -0
  149. package/src/daemon/ipc-contract/work-items.ts +1 -7
  150. package/src/daemon/ipc-contract-inventory.json +5 -1
  151. package/src/daemon/ipc-contract.ts +5 -1
  152. package/src/daemon/lifecycle.ts +306 -266
  153. package/src/daemon/recording-intent.ts +0 -41
  154. package/src/daemon/response-tier.ts +2 -2
  155. package/src/daemon/server.ts +6 -6
  156. package/src/daemon/session-agent-loop-handlers.ts +34 -9
  157. package/src/daemon/session-agent-loop.ts +15 -8
  158. package/src/daemon/session-history.ts +3 -2
  159. package/src/daemon/session-media-retry.ts +3 -0
  160. package/src/daemon/session-messaging.ts +38 -4
  161. package/src/daemon/session-notifiers.ts +2 -2
  162. package/src/daemon/session-process.ts +256 -23
  163. package/src/daemon/session-queue-manager.ts +2 -0
  164. package/src/daemon/session-runtime-assembly.ts +39 -0
  165. package/src/daemon/session-skill-tools.ts +13 -4
  166. package/src/daemon/session-tool-setup.ts +5 -6
  167. package/src/daemon/session.ts +19 -8
  168. package/src/daemon/tls-certs.ts +55 -13
  169. package/src/daemon/tool-side-effects.ts +13 -5
  170. package/src/gallery/default-gallery.ts +32 -9
  171. package/src/influencer/client.ts +2 -1
  172. package/src/memory/channel-delivery-store.ts +37 -567
  173. package/src/memory/channel-guardian-store.ts +66 -1317
  174. package/src/memory/conflict-store.ts +4 -4
  175. package/src/memory/conversation-attention-store.ts +0 -3
  176. package/src/memory/conversation-crud.ts +668 -0
  177. package/src/memory/conversation-queries.ts +361 -0
  178. package/src/memory/conversation-store.ts +45 -983
  179. package/src/memory/db-connection.ts +3 -0
  180. package/src/memory/db-init.ts +25 -0
  181. package/src/memory/delivery-channels.ts +175 -0
  182. package/src/memory/delivery-crud.ts +211 -0
  183. package/src/memory/delivery-status.ts +199 -0
  184. package/src/memory/embedding-backend.ts +70 -4
  185. package/src/memory/embedding-local.ts +12 -2
  186. package/src/memory/entity-extractor.ts +3 -8
  187. package/src/memory/fts-reconciler.ts +121 -0
  188. package/src/memory/guardian-action-store.ts +366 -3
  189. package/src/memory/guardian-approvals.ts +569 -0
  190. package/src/memory/guardian-bindings.ts +130 -0
  191. package/src/memory/guardian-rate-limits.ts +196 -0
  192. package/src/memory/guardian-verification.ts +520 -0
  193. package/src/memory/job-handlers/index-maintenance.ts +2 -1
  194. package/src/memory/job-utils.ts +8 -5
  195. package/src/memory/jobs-store.ts +66 -6
  196. package/src/memory/jobs-worker.ts +23 -1
  197. package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
  198. package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
  199. package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
  200. package/src/memory/migrations/100-core-tables.ts +1 -1
  201. package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
  202. package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
  203. package/src/memory/migrations/112-assistant-inbox.ts +1 -1
  204. package/src/memory/migrations/113-late-migrations.ts +1 -1
  205. package/src/memory/migrations/116-messages-fts.ts +13 -0
  206. package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
  207. package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
  208. package/src/memory/migrations/index.ts +8 -3
  209. package/src/memory/migrations/validate-migration-state.ts +114 -15
  210. package/src/memory/qdrant-circuit-breaker.ts +105 -0
  211. package/src/memory/retriever.ts +46 -13
  212. package/src/memory/schema-migration.ts +3 -0
  213. package/src/memory/schema.ts +25 -7
  214. package/src/memory/search/semantic.ts +8 -90
  215. package/src/notifications/README.md +1 -1
  216. package/src/notifications/broadcaster.ts +20 -2
  217. package/src/notifications/conversation-pairing.ts +3 -3
  218. package/src/notifications/decision-engine.ts +173 -8
  219. package/src/notifications/deliveries-store.ts +27 -8
  220. package/src/notifications/preferences-store.ts +7 -7
  221. package/src/notifications/thread-candidates.ts +234 -0
  222. package/src/notifications/types.ts +18 -0
  223. package/src/permissions/defaults.ts +11 -1
  224. package/src/permissions/prompter.ts +17 -0
  225. package/src/permissions/trust-store.ts +2 -0
  226. package/src/providers/failover.ts +19 -0
  227. package/src/providers/registry.ts +46 -1
  228. package/src/runtime/approval-message-composer.ts +1 -1
  229. package/src/runtime/channel-guardian-service.ts +15 -3
  230. package/src/runtime/channel-retry-sweep.ts +7 -2
  231. package/src/runtime/guardian-action-conversation-turn.ts +85 -0
  232. package/src/runtime/guardian-action-followup-executor.ts +301 -0
  233. package/src/runtime/guardian-action-message-composer.ts +245 -0
  234. package/src/runtime/guardian-outbound-actions.ts +26 -6
  235. package/src/runtime/guardian-verification-templates.ts +15 -9
  236. package/src/runtime/http-errors.ts +93 -0
  237. package/src/runtime/http-server.ts +133 -44
  238. package/src/runtime/http-types.ts +53 -0
  239. package/src/runtime/ingress-service.ts +237 -0
  240. package/src/runtime/middleware/error-handler.ts +4 -3
  241. package/src/runtime/middleware/rate-limiter.ts +160 -0
  242. package/src/runtime/middleware/request-logger.ts +71 -0
  243. package/src/runtime/middleware/twilio-validation.ts +7 -6
  244. package/src/runtime/pending-interactions.ts +12 -0
  245. package/src/runtime/routes/access-request-decision.ts +215 -0
  246. package/src/runtime/routes/app-routes.ts +25 -18
  247. package/src/runtime/routes/approval-routes.ts +18 -47
  248. package/src/runtime/routes/attachment-routes.ts +15 -41
  249. package/src/runtime/routes/call-routes.ts +20 -20
  250. package/src/runtime/routes/channel-delivery-routes.ts +6 -5
  251. package/src/runtime/routes/contact-routes.ts +4 -9
  252. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  253. package/src/runtime/routes/conversation-routes.ts +26 -57
  254. package/src/runtime/routes/debug-routes.ts +71 -0
  255. package/src/runtime/routes/events-routes.ts +3 -2
  256. package/src/runtime/routes/guardian-approval-interception.ts +221 -0
  257. package/src/runtime/routes/identity-routes.ts +14 -10
  258. package/src/runtime/routes/inbound-conversation.ts +3 -2
  259. package/src/runtime/routes/inbound-message-handler.ts +527 -62
  260. package/src/runtime/routes/ingress-routes.ts +174 -0
  261. package/src/runtime/routes/integration-routes.ts +78 -16
  262. package/src/runtime/routes/pairing-routes.ts +11 -10
  263. package/src/runtime/routes/secret-routes.ts +10 -18
  264. package/src/runtime/verification-rate-limiter.ts +83 -0
  265. package/src/schedule/schedule-store.ts +13 -1
  266. package/src/schedule/scheduler.ts +1 -1
  267. package/src/security/secret-ingress.ts +5 -2
  268. package/src/security/secret-scanner.ts +72 -6
  269. package/src/subagent/manager.ts +6 -4
  270. package/src/swarm/plan-validator.ts +4 -1
  271. package/src/tasks/task-runner.ts +3 -1
  272. package/src/tools/browser/api-map.ts +9 -6
  273. package/src/tools/calls/call-start.ts +20 -0
  274. package/src/tools/executor.ts +50 -568
  275. package/src/tools/permission-checker.ts +272 -0
  276. package/src/tools/registry.ts +14 -6
  277. package/src/tools/reminder/reminder-store.ts +7 -7
  278. package/src/tools/reminder/reminder.ts +6 -3
  279. package/src/tools/secret-detection-handler.ts +301 -0
  280. package/src/tools/subagent/message.ts +1 -1
  281. package/src/tools/system/voice-config.ts +62 -0
  282. package/src/tools/tasks/index.ts +3 -3
  283. package/src/tools/tasks/work-item-list.ts +3 -3
  284. package/src/tools/tasks/work-item-update.ts +4 -5
  285. package/src/tools/tool-approval-handler.ts +192 -0
  286. package/src/tools/tool-manifest.ts +2 -0
  287. package/src/watcher/watcher-store.ts +9 -9
  288. package/src/work-items/work-item-runner.ts +9 -6
  289. /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
  290. /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
@@ -232,10 +232,10 @@ describe('attachment orphan cleanup', () => {
232
232
  db.run('DELETE FROM conversations');
233
233
  });
234
234
 
235
- test('deleteLastExchange cleans up orphaned attachments', () => {
235
+ test('deleteLastExchange cleans up orphaned attachments', async () => {
236
236
  const conv = createConversation('test');
237
- addMessage(conv.id, 'user', 'hello');
238
- const assistantMsg = addMessage(conv.id, 'assistant', 'Here is a file');
237
+ await addMessage(conv.id, 'user', 'hello');
238
+ const assistantMsg = await addMessage(conv.id, 'assistant', 'Here is a file');
239
239
 
240
240
  const stored = uploadAttachment('chart.png', 'image/png', 'iVBOR');
241
241
  linkAttachmentToMessage(assistantMsg.id, stored.id, 0);
@@ -252,11 +252,11 @@ describe('attachment orphan cleanup', () => {
252
252
  expect(remaining.c).toBe(0);
253
253
  });
254
254
 
255
- test('deleteLastExchange preserves attachments still linked to other messages', () => {
255
+ test('deleteLastExchange preserves attachments still linked to other messages', async () => {
256
256
  const conv = createConversation('test');
257
- const msg1 = addMessage(conv.id, 'assistant', 'first');
258
- addMessage(conv.id, 'user', 'question');
259
- const msg2 = addMessage(conv.id, 'assistant', 'second');
257
+ const msg1 = await addMessage(conv.id, 'assistant', 'first');
258
+ await addMessage(conv.id, 'user', 'question');
259
+ const msg2 = await addMessage(conv.id, 'assistant', 'second');
260
260
 
261
261
  const shared = uploadAttachment('shared.png', 'image/png', 'AAAA');
262
262
  linkAttachmentToMessage(msg1.id, shared.id, 0);
@@ -271,9 +271,9 @@ describe('attachment orphan cleanup', () => {
271
271
  expect(remaining.c).toBe(1);
272
272
  });
273
273
 
274
- test('clearAll removes all attachments', () => {
274
+ test('clearAll removes all attachments', async () => {
275
275
  const conv = createConversation('test');
276
- const msg = addMessage(conv.id, 'assistant', 'file');
276
+ const msg = await addMessage(conv.id, 'assistant', 'file');
277
277
  const stored = uploadAttachment('doc.pdf', 'application/pdf', 'JVBER');
278
278
  linkAttachmentToMessage(msg.id, stored.id, 0);
279
279
 
@@ -286,10 +286,10 @@ describe('attachment orphan cleanup', () => {
286
286
  expect(linkCount.c).toBe(0);
287
287
  });
288
288
 
289
- test('deleteLastExchange does not delete unlinked user uploads', () => {
289
+ test('deleteLastExchange does not delete unlinked user uploads', async () => {
290
290
  const conv = createConversation('test');
291
- addMessage(conv.id, 'user', 'hello');
292
- const assistantMsg = addMessage(conv.id, 'assistant', 'Here is a file');
291
+ await addMessage(conv.id, 'user', 'hello');
292
+ const assistantMsg = await addMessage(conv.id, 'assistant', 'Here is a file');
293
293
 
294
294
  // An attachment linked to the assistant message (should be cleaned up)
295
295
  const linked = uploadAttachment('chart.png', 'image/png', 'iVBOR');
@@ -438,15 +438,15 @@ describe('attachment reuse across thread lifecycles', () => {
438
438
  db.run('DELETE FROM conversations');
439
439
  });
440
440
 
441
- test('attachment uploaded in conversation A is retrievable by ID without any conversation reference', () => {
441
+ test('attachment uploaded in conversation A is retrievable by ID without any conversation reference', async () => {
442
442
  const convA = createConversation('Thread A');
443
- const msgA = addMessage(convA.id, 'assistant', 'Here is a file');
443
+ const msgA = await addMessage(convA.id, 'assistant', 'Here is a file');
444
444
  const stored = uploadAttachment('report.pdf', 'application/pdf', 'JVBER');
445
445
  linkAttachmentToMessage(msgA.id, stored.id, 0);
446
446
 
447
447
  // Create a completely separate conversation
448
448
  const convB = createConversation('Thread B');
449
- addMessage(convB.id, 'user', 'hello');
449
+ await addMessage(convB.id, 'user', 'hello');
450
450
 
451
451
  // The attachment is retrievable by ID regardless of which conversation is active.
452
452
  const fetched = getAttachmentById(stored.id);
@@ -456,12 +456,12 @@ describe('attachment reuse across thread lifecycles', () => {
456
456
  expect(fetched!.dataBase64).toBe('JVBER');
457
457
  });
458
458
 
459
- test('attachment can be linked to messages in different conversations', () => {
459
+ test('attachment can be linked to messages in different conversations', async () => {
460
460
  const convA = createConversation('Thread A');
461
461
  const convB = createConversation('Thread B');
462
462
 
463
- const msgA = addMessage(convA.id, 'assistant', 'Original file');
464
- const msgB = addMessage(convB.id, 'assistant', 'Reused file');
463
+ const msgA = await addMessage(convA.id, 'assistant', 'Original file');
464
+ const msgB = await addMessage(convB.id, 'assistant', 'Reused file');
465
465
 
466
466
  // Upload once, link to both conversations
467
467
  const stored = uploadAttachment('shared.png', 'image/png', 'iVBORw0K');
@@ -478,16 +478,16 @@ describe('attachment reuse across thread lifecycles', () => {
478
478
  expect(linkedB[0].id).toBe(stored.id);
479
479
  });
480
480
 
481
- test('deleting conversation A does not orphan attachment reused in conversation B', () => {
481
+ test('deleting conversation A does not orphan attachment reused in conversation B', async () => {
482
482
  const convA = createConversation('Thread A');
483
483
  const convB = createConversation('Thread B');
484
484
 
485
485
  // deleteLastExchange deletes from the last user message onward,
486
486
  // so we need a user message before the assistant message that carries the attachment.
487
- addMessage(convA.id, 'user', 'Please generate a chart');
488
- const msgA = addMessage(convA.id, 'assistant', 'Original');
489
- addMessage(convB.id, 'user', 'Show me the chart');
490
- const msgB = addMessage(convB.id, 'assistant', 'Reused');
487
+ await addMessage(convA.id, 'user', 'Please generate a chart');
488
+ const msgA = await addMessage(convA.id, 'assistant', 'Original');
489
+ await addMessage(convB.id, 'user', 'Show me the chart');
490
+ const msgB = await addMessage(convB.id, 'assistant', 'Reused');
491
491
 
492
492
  const stored = uploadAttachment('chart.png', 'image/png', 'AAAA');
493
493
  linkAttachmentToMessage(msgA.id, stored.id, 0);
@@ -506,12 +506,12 @@ describe('attachment reuse across thread lifecycles', () => {
506
506
  expect(linkedB[0].id).toBe(stored.id);
507
507
  });
508
508
 
509
- test('content-hash dedup works across conversations', () => {
509
+ test('content-hash dedup works across conversations', async () => {
510
510
  const convA = createConversation('Thread A');
511
511
  const convB = createConversation('Thread B');
512
512
 
513
- addMessage(convA.id, 'user', 'upload in A');
514
- addMessage(convB.id, 'user', 'upload in B');
513
+ await addMessage(convA.id, 'user', 'upload in A');
514
+ await addMessage(convB.id, 'user', 'upload in B');
515
515
 
516
516
  // Same content uploaded in two different conversation contexts
517
517
  const first = uploadAttachment('photo.png', 'image/png', 'DEDUPCROSS');
@@ -535,11 +535,11 @@ describe('no private-thread attachment visibility boundary', () => {
535
535
  db.run('DELETE FROM conversations');
536
536
  });
537
537
 
538
- test('attachment from a private thread is visible via getAttachmentById (no thread scoping)', () => {
538
+ test('attachment from a private thread is visible via getAttachmentById (no thread scoping)', async () => {
539
539
  const privateConv = createConversation({ title: 'Secret', threadType: 'private' });
540
540
  expect(privateConv.threadType).toBe('private');
541
541
 
542
- const msg = addMessage(privateConv.id, 'assistant', 'Private content');
542
+ const msg = await addMessage(privateConv.id, 'assistant', 'Private content');
543
543
  const stored = uploadAttachment('secret.pdf', 'application/pdf', 'JVBER');
544
544
  linkAttachmentToMessage(msg.id, stored.id, 0);
545
545
 
@@ -549,12 +549,12 @@ describe('no private-thread attachment visibility boundary', () => {
549
549
  expect(fetched!.originalFilename).toBe('secret.pdf');
550
550
  });
551
551
 
552
- test('attachment from private thread can be linked to a standard thread message', () => {
552
+ test('attachment from private thread can be linked to a standard thread message', async () => {
553
553
  const privateConv = createConversation({ title: 'Private', threadType: 'private' });
554
554
  const standardConv = createConversation({ title: 'Standard', threadType: 'standard' });
555
555
 
556
- const privateMsg = addMessage(privateConv.id, 'assistant', 'Private file');
557
- const standardMsg = addMessage(standardConv.id, 'assistant', 'Reusing private file');
556
+ const privateMsg = await addMessage(privateConv.id, 'assistant', 'Private file');
557
+ const standardMsg = await addMessage(standardConv.id, 'assistant', 'Reusing private file');
558
558
 
559
559
  const stored = uploadAttachment('private-doc.png', 'image/png', 'PRIVDATA');
560
560
  linkAttachmentToMessage(privateMsg.id, stored.id, 0);
@@ -569,9 +569,9 @@ describe('no private-thread attachment visibility boundary', () => {
569
569
  expect(linkedStandard[0].id).toBe(stored.id);
570
570
  });
571
571
 
572
- test('getAttachmentsForMessage returns private thread attachments', () => {
572
+ test('getAttachmentsForMessage returns private thread attachments', async () => {
573
573
  const privateConv = createConversation({ title: 'Private', threadType: 'private' });
574
- const msg = addMessage(privateConv.id, 'assistant', 'Private media');
574
+ const msg = await addMessage(privateConv.id, 'assistant', 'Private media');
575
575
  const stored = uploadAttachment('photo.jpg', 'image/jpeg', 'AAAA');
576
576
  linkAttachmentToMessage(msg.id, stored.id, 0);
577
577
 
@@ -592,12 +592,12 @@ describe('no private-thread attachment visibility boundary', () => {
592
592
  expect(fromStandard.id).toBe(fromPrivate.id);
593
593
  });
594
594
 
595
- test('clearAll removes attachments from both private and standard threads', () => {
595
+ test('clearAll removes attachments from both private and standard threads', async () => {
596
596
  const privateConv = createConversation({ title: 'Private', threadType: 'private' });
597
597
  const standardConv = createConversation({ title: 'Standard', threadType: 'standard' });
598
598
 
599
- const privateMsg = addMessage(privateConv.id, 'assistant', 'Private file');
600
- const standardMsg = addMessage(standardConv.id, 'assistant', 'Standard file');
599
+ const privateMsg = await addMessage(privateConv.id, 'assistant', 'Private file');
600
+ const standardMsg = await addMessage(standardConv.id, 'assistant', 'Standard file');
601
601
 
602
602
  const att1 = uploadAttachment('private.png', 'image/png', 'PRIV');
603
603
  const att2 = uploadAttachment('standard.png', 'image/png', 'STD');
@@ -1,6 +1,6 @@
1
1
  import { describe, expect,test } from 'bun:test';
2
2
 
3
- import { buildTemporalContext } from '../daemon/date-context.js';
3
+ import { buildTemporalContext, extractUserTimeZoneFromDynamicProfile } from '../daemon/date-context.js';
4
4
 
5
5
  // Fixed timestamps for deterministic assertions (all UTC midday to avoid DST edge cases).
6
6
 
@@ -40,11 +40,181 @@ describe('buildTemporalContext', () => {
40
40
  expect(result).toContain('Timezone: America/New_York');
41
41
  });
42
42
 
43
+ test('includes current local time as ISO 8601 with offset', () => {
44
+ const result = buildTemporalContext({ nowMs: WED_FEB_18, timeZone: 'UTC' });
45
+ expect(result).toContain('Current local time: 2026-02-18T12:00:00+00:00');
46
+ });
47
+
48
+ test('includes current UTC time from assistant host clock', () => {
49
+ const result = buildTemporalContext({ nowMs: WED_FEB_18, timeZone: 'UTC' });
50
+ expect(result).toContain('Current UTC time: 2026-02-18T12:00:00.000Z');
51
+ });
52
+
53
+ test('documents assistant host as the authoritative clock source', () => {
54
+ const result = buildTemporalContext({ nowMs: WED_FEB_18, timeZone: 'UTC' });
55
+ expect(result).toContain('Clock source: assistant host machine');
56
+ });
57
+
58
+ test('uses user timezone when provided and records source metadata', () => {
59
+ const result = buildTemporalContext({
60
+ nowMs: WED_FEB_18,
61
+ hostTimeZone: 'UTC',
62
+ userTimeZone: 'America/New_York',
63
+ });
64
+ expect(result).toContain('Timezone: America/New_York');
65
+ expect(result).toContain('Current local time: 2026-02-18T07:00:00-05:00');
66
+ expect(result).toContain('Assistant host timezone: UTC');
67
+ expect(result).toContain('User timezone: America/New_York');
68
+ expect(result).toContain('Timezone source: user_profile_memory');
69
+ });
70
+
71
+ test('uses configured user timezone when profile timezone is unavailable', () => {
72
+ const result = buildTemporalContext({
73
+ nowMs: WED_FEB_18,
74
+ hostTimeZone: 'UTC',
75
+ configuredUserTimeZone: 'America/Chicago',
76
+ userTimeZone: null,
77
+ });
78
+ expect(result).toContain('Timezone: America/Chicago');
79
+ expect(result).toContain('Current local time: 2026-02-18T06:00:00-06:00');
80
+ expect(result).toContain('User timezone: America/Chicago');
81
+ expect(result).toContain('Timezone source: user_settings');
82
+ });
83
+
84
+ test('configured user timezone takes precedence over profile timezone', () => {
85
+ const result = buildTemporalContext({
86
+ nowMs: WED_FEB_18,
87
+ hostTimeZone: 'UTC',
88
+ configuredUserTimeZone: 'America/Los_Angeles',
89
+ userTimeZone: 'America/New_York',
90
+ });
91
+ expect(result).toContain('Timezone: America/Los_Angeles');
92
+ expect(result).toContain('Current local time: 2026-02-18T04:00:00-08:00');
93
+ expect(result).toContain('User timezone: America/Los_Angeles');
94
+ expect(result).toContain('Timezone source: user_settings');
95
+ });
96
+
97
+ test('falls back to host timezone when user timezone is unavailable', () => {
98
+ const result = buildTemporalContext({
99
+ nowMs: WED_FEB_18,
100
+ hostTimeZone: 'UTC',
101
+ userTimeZone: null,
102
+ });
103
+ expect(result).toContain('Timezone: UTC');
104
+ expect(result).toContain('User timezone: unknown');
105
+ expect(result).toContain('Timezone source: assistant_host_fallback');
106
+ });
107
+
108
+ test('accepts UTC/GMT offset-style user timezone values', () => {
109
+ const result = buildTemporalContext({
110
+ nowMs: WED_FEB_18,
111
+ hostTimeZone: 'UTC',
112
+ userTimeZone: 'UTC+2',
113
+ });
114
+ expect(result).toContain('Timezone: Etc/GMT-2');
115
+ expect(result).toContain('Current local time: 2026-02-18T14:00:00+02:00');
116
+ expect(result).toContain('User timezone: Etc/GMT-2');
117
+ expect(result).toContain('Timezone source: user_profile_memory');
118
+ });
119
+
120
+ test('accepts fractional UTC/GMT offset-style user timezone values', () => {
121
+ const result = buildTemporalContext({
122
+ nowMs: WED_FEB_18,
123
+ hostTimeZone: 'UTC',
124
+ userTimeZone: 'UTC+5:30',
125
+ });
126
+ expect(result).toContain('Timezone: +05:30');
127
+ expect(result).toContain('Current local time: 2026-02-18T17:30:00+05:30');
128
+ expect(result).toContain('User timezone: +05:30');
129
+ expect(result).toContain('Timezone source: user_profile_memory');
130
+ });
131
+
43
132
  test('includes week definitions', () => {
44
133
  const result = buildTemporalContext({ nowMs: WED_FEB_18, timeZone: 'UTC' });
45
134
  expect(result).toContain('work week = Monday–Friday');
46
135
  expect(result).toContain('weekend = Saturday–Sunday');
47
136
  });
137
+
138
+ test('formats midnight hours as 00 (never 24) in local ISO output', () => {
139
+ const justAfterMidnight = Date.UTC(2026, 1, 19, 0, 5, 0);
140
+ const result = buildTemporalContext({ nowMs: justAfterMidnight, timeZone: 'UTC' });
141
+ expect(result).toContain('Current local time: 2026-02-19T00:05:00+00:00');
142
+ expect(result).not.toContain('T24:05:00');
143
+ });
144
+ });
145
+
146
+ describe('extractUserTimeZoneFromDynamicProfile', () => {
147
+ test('extracts canonical timezone from explicit timezone profile line', () => {
148
+ const profile = [
149
+ '<dynamic-user-profile>',
150
+ '- timezone: Timezone is America/New_York.',
151
+ '</dynamic-user-profile>',
152
+ ].join('\n');
153
+ expect(extractUserTimeZoneFromDynamicProfile(profile)).toBe('America/New_York');
154
+ });
155
+
156
+ test('extracts timezone token from generic profile text when explicit line is absent', () => {
157
+ const profile = [
158
+ '<dynamic-user-profile>',
159
+ '- location: Travels often between Europe and Asia (currently Europe/Paris).',
160
+ '</dynamic-user-profile>',
161
+ ].join('\n');
162
+ expect(extractUserTimeZoneFromDynamicProfile(profile)).toBe('Europe/Paris');
163
+ });
164
+
165
+ test('returns null when no valid timezone is present', () => {
166
+ const profile = [
167
+ '<dynamic-user-profile>',
168
+ '- timezone: Pacific time',
169
+ '</dynamic-user-profile>',
170
+ ].join('\n');
171
+ expect(extractUserTimeZoneFromDynamicProfile(profile)).toBeNull();
172
+ });
173
+
174
+ test('extracts UTC/GMT offset tokens from explicit timezone profile line', () => {
175
+ const profile = [
176
+ '<dynamic-user-profile>',
177
+ '- timezone: UTC+2',
178
+ '</dynamic-user-profile>',
179
+ ].join('\n');
180
+ expect(extractUserTimeZoneFromDynamicProfile(profile)).toBe('Etc/GMT-2');
181
+ });
182
+
183
+ test('extracts GMT negative offset tokens from generic profile text', () => {
184
+ const profile = [
185
+ '<dynamic-user-profile>',
186
+ '- preferences: schedule notifications in GMT-5 whenever possible.',
187
+ '</dynamic-user-profile>',
188
+ ].join('\n');
189
+ expect(extractUserTimeZoneFromDynamicProfile(profile)).toBe('Etc/GMT+5');
190
+ });
191
+
192
+ test('extracts fractional UTC offset tokens from explicit timezone profile line', () => {
193
+ const profile = [
194
+ '<dynamic-user-profile>',
195
+ '- timezone: UTC+5:30',
196
+ '</dynamic-user-profile>',
197
+ ].join('\n');
198
+ expect(extractUserTimeZoneFromDynamicProfile(profile)).toBe('+05:30');
199
+ });
200
+
201
+ test('extracts fractional GMT offset tokens from generic profile text', () => {
202
+ const profile = [
203
+ '<dynamic-user-profile>',
204
+ '- preferences: default reminders to GMT+5:45.',
205
+ '</dynamic-user-profile>',
206
+ ].join('\n');
207
+ expect(extractUserTimeZoneFromDynamicProfile(profile)).toBe('+05:45');
208
+ });
209
+
210
+ test('prefers IANA timezone tokens over UTC/GMT offsets in the same profile line', () => {
211
+ const profile = [
212
+ '<dynamic-user-profile>',
213
+ '- timezone: UTC+1 (Europe/Paris)',
214
+ '</dynamic-user-profile>',
215
+ ].join('\n');
216
+ expect(extractUserTimeZoneFromDynamicProfile(profile)).toBe('Europe/Paris');
217
+ });
48
218
  });
49
219
 
50
220
  // ---------------------------------------------------------------------------
@@ -186,6 +356,7 @@ describe('DST-safe timezone behavior', () => {
186
356
  // Feb 18 12:00 UTC = Feb 18 07:00 EST (same calendar date)
187
357
  const result = buildTemporalContext({ nowMs: WED_FEB_18, timeZone: 'America/New_York' });
188
358
  expect(result).toContain('Today: 2026-02-18 (Wednesday)');
359
+ expect(result).toContain('Current local time: 2026-02-18T07:00:00-05:00');
189
360
  });
190
361
 
191
362
  test('date labels are correct in timezone ahead of UTC', () => {
@@ -234,6 +405,13 @@ describe('DST-safe timezone behavior', () => {
234
405
  expect(result).toContain('2026-02-21 Saturday');
235
406
  expect(result).toContain('2026-02-22 Sunday');
236
407
  });
408
+
409
+ test('local offset tracks daylight saving changes', () => {
410
+ // Jul 1 12:00 UTC = Jul 1 08:00 EDT
411
+ const summer = Date.UTC(2026, 6, 1, 12, 0, 0);
412
+ const result = buildTemporalContext({ nowMs: summer, timeZone: 'America/New_York' });
413
+ expect(result).toContain('Current local time: 2026-07-01T08:00:00-04:00');
414
+ });
237
415
  });
238
416
 
239
417
  // ---------------------------------------------------------------------------
@@ -445,13 +445,10 @@ describe('schema-drift recovery: migration handles unexpected schema state', ()
445
445
  VALUES ('migration_memory_items_scope_salted_fingerprints_v1', '1', ${now})
446
446
  `);
447
447
 
448
- // validateMigrationState should not throw it logs errors but continues.
449
- // Assert directly on the returned result to verify the function itself reports
450
- // the dependency violation (not just that the registry declares a dependency).
451
- const result: MigrationValidationResult = validateMigrationState(db);
452
- expect(result.dependencyViolations).toHaveLength(1);
453
- expect(result.dependencyViolations[0].migration).toBe('migration_memory_items_scope_salted_fingerprints_v1');
454
- expect(result.dependencyViolations[0].missingDependency).toBe('migration_memory_items_fingerprint_scope_unique_v1');
448
+ // validateMigrationState throws an IntegrityError on dependency violations
449
+ // to block daemon startup with an inconsistent schema.
450
+ expect(() => validateMigrationState(db)).toThrow('Migration dependency violations detected');
451
+ expect(() => validateMigrationState(db)).toThrow('migration_memory_items_fingerprint_scope_unique_v1');
455
452
 
456
453
  // Sanity-check: confirm the registry also declares this dependency, so the
457
454
  // violation detection is grounded in real schema intent.
@@ -2,7 +2,7 @@
2
2
  * Tests for the deterministic verification control plane (M1).
3
3
  *
4
4
  * Verifies that:
5
- * 1. Verification commands (/guardian_verify, /start gv_<token>) never invoke
5
+ * 1. Verification control messages (code replies, /start gv_<token>) never invoke
6
6
  * the normal message pipeline — they produce only template-driven copy.
7
7
  * 2. Call session mode metadata is persisted correctly for guardian verification calls.
8
8
  * 3. TwiML generation includes guardian verification parameters when relevant.
@@ -208,8 +208,8 @@ describe('Call session mode metadata', () => {
208
208
  // Guard test: verification commands must not reach processMessage
209
209
  // ---------------------------------------------------------------------------
210
210
 
211
- describe('Verification commands are deterministic (guard)', () => {
212
- test('handleChannelInbound does not call processMessage for /guardian_verify commands', async () => {
211
+ describe('Verification control messages are deterministic (guard)', () => {
212
+ test('handleChannelInbound does not call processMessage for verification code replies', async () => {
213
213
  const { createHash } = await import('node:crypto');
214
214
  const { handleChannelInbound } = await import('../runtime/routes/inbound-message-handler.js');
215
215
  const {
@@ -217,7 +217,7 @@ describe('Verification commands are deterministic (guard)', () => {
217
217
  } = await import('../memory/channel-guardian-store.js');
218
218
 
219
219
  // Set up a pending challenge
220
- const secret = 'test-secret-abc';
220
+ const secret = '123456';
221
221
  const challengeHash = createHash('sha256').update(secret).digest('hex');
222
222
  createChallenge({
223
223
  id: 'challenge-guard-test',
@@ -260,7 +260,7 @@ describe('Verification commands are deterministic (guard)', () => {
260
260
  interface: 'telegram',
261
261
  externalChatId: 'chat-123',
262
262
  externalMessageId: `msg-guard-${Date.now()}`,
263
- content: `/guardian_verify ${secret}`,
263
+ content: secret,
264
264
  senderExternalUserId: 'user-123',
265
265
  senderName: 'Test User',
266
266
  replyCallbackUrl: 'http://localhost/callback',
@@ -0,0 +1,179 @@
1
+ import { beforeEach, describe, expect, mock, test } from 'bun:test';
2
+
3
+ const evaluateSignalMock = mock();
4
+ const enforceRoutingIntentMock = mock();
5
+ const updateDecisionMock = mock();
6
+ const runDeterministicChecksMock = mock();
7
+ const createEventMock = mock();
8
+ const updateEventDedupeKeyMock = mock();
9
+ const dispatchDecisionMock = mock();
10
+
11
+ mock.module('../util/logger.js', () => ({
12
+ getLogger: () =>
13
+ new Proxy({} as Record<string, unknown>, {
14
+ get: () => () => {},
15
+ }),
16
+ }));
17
+
18
+ mock.module('../channels/config.js', () => ({
19
+ getDeliverableChannels: () => ['vellum', 'telegram'],
20
+ }));
21
+
22
+ mock.module('../memory/channel-guardian-store.js', () => ({
23
+ getActiveBinding: (_assistantId: string, channel: string) =>
24
+ channel === 'telegram'
25
+ ? {
26
+ guardianDeliveryChatId: 'guardian-chat-123',
27
+ guardianExternalUserId: 'guardian-user-123',
28
+ }
29
+ : null,
30
+ }));
31
+
32
+ mock.module('../notifications/adapters/macos.js', () => ({
33
+ VellumAdapter: class {
34
+ constructor(_broadcastFn: unknown) {}
35
+ },
36
+ }));
37
+
38
+ mock.module('../notifications/adapters/sms.js', () => ({
39
+ SmsAdapter: class {},
40
+ }));
41
+
42
+ mock.module('../notifications/adapters/telegram.js', () => ({
43
+ TelegramAdapter: class {},
44
+ }));
45
+
46
+ mock.module('../notifications/broadcaster.js', () => ({
47
+ NotificationBroadcaster: class {
48
+ constructor(_adapters: unknown[]) {}
49
+ setOnThreadCreated(_fn: unknown) {}
50
+ },
51
+ }));
52
+
53
+ mock.module('../notifications/decision-engine.js', () => ({
54
+ evaluateSignal: (...args: unknown[]) => evaluateSignalMock(...args),
55
+ enforceRoutingIntent: (...args: unknown[]) => enforceRoutingIntentMock(...args),
56
+ }));
57
+
58
+ mock.module('../notifications/decisions-store.js', () => ({
59
+ updateDecision: (...args: unknown[]) => updateDecisionMock(...args),
60
+ }));
61
+
62
+ mock.module('../notifications/deterministic-checks.js', () => ({
63
+ runDeterministicChecks: (...args: unknown[]) => runDeterministicChecksMock(...args),
64
+ }));
65
+
66
+ mock.module('../notifications/events-store.js', () => ({
67
+ createEvent: (...args: unknown[]) => createEventMock(...args),
68
+ updateEventDedupeKey: (...args: unknown[]) => updateEventDedupeKeyMock(...args),
69
+ }));
70
+
71
+ mock.module('../notifications/runtime-dispatch.js', () => ({
72
+ dispatchDecision: (...args: unknown[]) => dispatchDecisionMock(...args),
73
+ }));
74
+
75
+ import { emitNotificationSignal } from '../notifications/emit-signal.js';
76
+
77
+ describe('emitNotificationSignal routing intent re-persistence', () => {
78
+ beforeEach(() => {
79
+ evaluateSignalMock.mockReset();
80
+ enforceRoutingIntentMock.mockReset();
81
+ updateDecisionMock.mockReset();
82
+ runDeterministicChecksMock.mockReset();
83
+ createEventMock.mockReset();
84
+ updateEventDedupeKeyMock.mockReset();
85
+ dispatchDecisionMock.mockReset();
86
+
87
+ createEventMock.mockReturnValue({ id: 'evt-1' });
88
+ runDeterministicChecksMock.mockResolvedValue({ passed: true });
89
+ dispatchDecisionMock.mockResolvedValue({
90
+ dispatched: true,
91
+ reason: 'ok',
92
+ deliveryResults: [],
93
+ });
94
+ });
95
+
96
+ test('re-persists selectedChannels/reasoningSummary when enforcement changes the decision', async () => {
97
+ const preDecision = {
98
+ shouldNotify: true,
99
+ selectedChannels: ['vellum'],
100
+ reasoningSummary: 'LLM selected vellum only',
101
+ renderedCopy: {
102
+ vellum: { title: 'Reminder', body: 'Take out trash' },
103
+ },
104
+ dedupeKey: 'dedupe-rem-1',
105
+ confidence: 0.9,
106
+ fallbackUsed: false,
107
+ persistedDecisionId: 'dec-1',
108
+ };
109
+
110
+ const enforcedDecision = {
111
+ ...preDecision,
112
+ selectedChannels: ['vellum', 'telegram'],
113
+ reasoningSummary: `${preDecision.reasoningSummary} [routing_intent=all_channels enforced: vellum, telegram]`,
114
+ };
115
+
116
+ evaluateSignalMock.mockResolvedValue(preDecision);
117
+ enforceRoutingIntentMock.mockReturnValue(enforcedDecision);
118
+
119
+ const result = await emitNotificationSignal({
120
+ sourceEventName: 'reminder.fired',
121
+ sourceChannel: 'scheduler',
122
+ sourceSessionId: 'rem-1',
123
+ attentionHints: {
124
+ requiresAction: true,
125
+ urgency: 'high',
126
+ isAsyncBackground: false,
127
+ visibleInSourceNow: false,
128
+ },
129
+ contextPayload: { reminderId: 'rem-1' },
130
+ routingIntent: 'all_channels',
131
+ });
132
+
133
+ expect(result.dispatched).toBe(true);
134
+ expect(updateDecisionMock).toHaveBeenCalledTimes(1);
135
+ expect(updateDecisionMock).toHaveBeenCalledWith('dec-1', {
136
+ selectedChannels: ['vellum', 'telegram'],
137
+ reasoningSummary: `${preDecision.reasoningSummary} [routing_intent=all_channels enforced: vellum, telegram]`,
138
+ validationResults: {
139
+ dedupeKey: 'dedupe-rem-1',
140
+ channelCount: 2,
141
+ hasCopy: true,
142
+ },
143
+ });
144
+ });
145
+
146
+ test('does not re-persist when enforcement leaves the decision unchanged', async () => {
147
+ const decision = {
148
+ shouldNotify: true,
149
+ selectedChannels: ['vellum'],
150
+ reasoningSummary: 'No routing override needed',
151
+ renderedCopy: {
152
+ vellum: { title: 'Reminder', body: 'Drink water' },
153
+ },
154
+ dedupeKey: 'dedupe-rem-2',
155
+ confidence: 0.8,
156
+ fallbackUsed: false,
157
+ persistedDecisionId: 'dec-2',
158
+ };
159
+
160
+ evaluateSignalMock.mockResolvedValue(decision);
161
+ enforceRoutingIntentMock.mockImplementation((inputDecision: unknown) => inputDecision);
162
+
163
+ await emitNotificationSignal({
164
+ sourceEventName: 'reminder.fired',
165
+ sourceChannel: 'scheduler',
166
+ sourceSessionId: 'rem-2',
167
+ attentionHints: {
168
+ requiresAction: false,
169
+ urgency: 'medium',
170
+ isAsyncBackground: false,
171
+ visibleInSourceNow: false,
172
+ },
173
+ contextPayload: { reminderId: 'rem-2' },
174
+ routingIntent: 'single_channel',
175
+ });
176
+
177
+ expect(updateDecisionMock).not.toHaveBeenCalled();
178
+ });
179
+ });