@vellumai/assistant 0.3.27 → 0.4.0

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 (247) hide show
  1. package/ARCHITECTURE.md +81 -4
  2. package/Dockerfile +2 -2
  3. package/bun.lock +4 -1
  4. package/docs/trusted-contact-access.md +9 -2
  5. package/package.json +6 -3
  6. package/scripts/ipc/generate-swift.ts +9 -5
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  8. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  9. package/src/__tests__/agent-loop.test.ts +119 -0
  10. package/src/__tests__/approval-routes-http.test.ts +13 -5
  11. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  12. package/src/__tests__/asset-search-tool.test.ts +2 -0
  13. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  14. package/src/__tests__/attachments-store.test.ts +2 -0
  15. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  16. package/src/__tests__/bundled-asset.test.ts +107 -0
  17. package/src/__tests__/call-controller.test.ts +30 -29
  18. package/src/__tests__/call-routes-http.test.ts +34 -32
  19. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  20. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  21. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  22. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  23. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  24. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  25. package/src/__tests__/clarification-resolver.test.ts +2 -0
  26. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  27. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  28. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  29. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  30. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  31. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  32. package/src/__tests__/config-schema.test.ts +5 -5
  33. package/src/__tests__/config-watcher.test.ts +3 -1
  34. package/src/__tests__/connection-policy.test.ts +14 -5
  35. package/src/__tests__/contacts-tools.test.ts +3 -1
  36. package/src/__tests__/contradiction-checker.test.ts +2 -0
  37. package/src/__tests__/conversation-pairing.test.ts +10 -0
  38. package/src/__tests__/conversation-routes.test.ts +1 -1
  39. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  40. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  41. package/src/__tests__/credential-vault.test.ts +5 -4
  42. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  43. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  44. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  45. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  46. package/src/__tests__/encrypted-store.test.ts +10 -5
  47. package/src/__tests__/followup-tools.test.ts +3 -1
  48. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  49. package/src/__tests__/gmail-integration.test.ts +0 -1
  50. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  51. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  52. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  53. package/src/__tests__/guardian-dispatch.test.ts +21 -19
  54. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  55. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  56. package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
  57. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  58. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  59. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  60. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  61. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  62. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  63. package/src/__tests__/heartbeat-service.test.ts +20 -0
  64. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  65. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  66. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  67. package/src/__tests__/intent-routing.test.ts +2 -0
  68. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  69. package/src/__tests__/mcp-cli.test.ts +77 -0
  70. package/src/__tests__/media-generate-image.test.ts +21 -0
  71. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  72. package/src/__tests__/memory-regressions.test.ts +20 -20
  73. package/src/__tests__/non-member-access-request.test.ts +212 -36
  74. package/src/__tests__/notification-decision-fallback.test.ts +63 -3
  75. package/src/__tests__/notification-decision-strategy.test.ts +78 -0
  76. package/src/__tests__/notification-guardian-path.test.ts +15 -15
  77. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  78. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  79. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  80. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  81. package/src/__tests__/pairing-routes.test.ts +171 -0
  82. package/src/__tests__/playbook-execution.test.ts +3 -1
  83. package/src/__tests__/playbook-tools.test.ts +3 -1
  84. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  85. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  86. package/src/__tests__/recording-handler.test.ts +11 -0
  87. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  88. package/src/__tests__/recording-state-machine.test.ts +13 -2
  89. package/src/__tests__/registry.test.ts +7 -3
  90. package/src/__tests__/relay-server.test.ts +148 -28
  91. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  92. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  93. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  94. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  95. package/src/__tests__/schedule-tools.test.ts +3 -1
  96. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  97. package/src/__tests__/secret-scanner.test.ts +8 -0
  98. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  99. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  100. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  101. package/src/__tests__/session-agent-loop.test.ts +16 -0
  102. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  103. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  104. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  105. package/src/__tests__/session-profile-injection.test.ts +21 -0
  106. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  107. package/src/__tests__/session-queue.test.ts +23 -0
  108. package/src/__tests__/session-runtime-assembly.test.ts +126 -59
  109. package/src/__tests__/session-skill-tools.test.ts +27 -5
  110. package/src/__tests__/session-slash-known.test.ts +23 -0
  111. package/src/__tests__/session-slash-queue.test.ts +23 -0
  112. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  113. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  114. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  115. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  116. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  117. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  118. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  119. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  120. package/src/__tests__/skills.test.ts +8 -4
  121. package/src/__tests__/slack-channel-config.test.ts +3 -1
  122. package/src/__tests__/subagent-tools.test.ts +19 -0
  123. package/src/__tests__/swarm-recursion.test.ts +2 -0
  124. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  125. package/src/__tests__/swarm-tool.test.ts +2 -0
  126. package/src/__tests__/system-prompt.test.ts +3 -1
  127. package/src/__tests__/task-compiler.test.ts +3 -1
  128. package/src/__tests__/task-management-tools.test.ts +3 -1
  129. package/src/__tests__/task-tools.test.ts +3 -1
  130. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  131. package/src/__tests__/terminal-tools.test.ts +2 -0
  132. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  133. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  134. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  135. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  136. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  137. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  138. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  139. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  140. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  141. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  142. package/src/__tests__/view-image-tool.test.ts +3 -1
  143. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  144. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  145. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  146. package/src/__tests__/work-item-output.test.ts +3 -1
  147. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  148. package/src/agent/loop.ts +46 -3
  149. package/src/approvals/guardian-decision-primitive.ts +285 -0
  150. package/src/approvals/guardian-request-resolvers.ts +539 -0
  151. package/src/calls/call-controller.ts +26 -23
  152. package/src/calls/guardian-action-sweep.ts +10 -2
  153. package/src/calls/guardian-dispatch.ts +46 -40
  154. package/src/calls/relay-server.ts +358 -24
  155. package/src/calls/types.ts +1 -1
  156. package/src/calls/voice-session-bridge.ts +3 -3
  157. package/src/cli.ts +12 -0
  158. package/src/config/agent-schema.ts +14 -3
  159. package/src/config/calls-schema.ts +6 -6
  160. package/src/config/core-schema.ts +3 -3
  161. package/src/config/feature-flag-registry.json +8 -0
  162. package/src/config/mcp-schema.ts +1 -1
  163. package/src/config/memory-schema.ts +27 -19
  164. package/src/config/schema.ts +21 -21
  165. package/src/config/skills-schema.ts +7 -7
  166. package/src/config/system-prompt.ts +2 -1
  167. package/src/config/templates/BOOTSTRAP.md +47 -31
  168. package/src/config/templates/USER.md +5 -0
  169. package/src/config/update-bulletin-template-path.ts +4 -1
  170. package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
  171. package/src/daemon/handlers/config-inbox.ts +4 -4
  172. package/src/daemon/handlers/guardian-actions.ts +45 -66
  173. package/src/daemon/handlers/sessions.ts +148 -4
  174. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  175. package/src/daemon/ipc-contract/messages.ts +16 -0
  176. package/src/daemon/ipc-contract-inventory.json +1 -0
  177. package/src/daemon/lifecycle.ts +22 -16
  178. package/src/daemon/pairing-store.ts +86 -3
  179. package/src/daemon/server.ts +18 -0
  180. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  181. package/src/daemon/session-agent-loop.ts +33 -6
  182. package/src/daemon/session-lifecycle.ts +25 -17
  183. package/src/daemon/session-memory.ts +2 -2
  184. package/src/daemon/session-process.ts +68 -326
  185. package/src/daemon/session-runtime-assembly.ts +119 -25
  186. package/src/daemon/session-tool-setup.ts +3 -2
  187. package/src/daemon/session.ts +4 -3
  188. package/src/home-base/prebuilt/seed.ts +2 -1
  189. package/src/hooks/templates.ts +2 -1
  190. package/src/memory/canonical-guardian-store.ts +586 -0
  191. package/src/memory/channel-guardian-store.ts +2 -0
  192. package/src/memory/conversation-crud.ts +7 -7
  193. package/src/memory/db-init.ts +20 -0
  194. package/src/memory/embedding-local.ts +257 -39
  195. package/src/memory/embedding-runtime-manager.ts +471 -0
  196. package/src/memory/guardian-action-store.ts +7 -60
  197. package/src/memory/guardian-approvals.ts +9 -4
  198. package/src/memory/guardian-bindings.ts +25 -1
  199. package/src/memory/indexer.ts +3 -3
  200. package/src/memory/ingress-invite-store.ts +45 -0
  201. package/src/memory/job-handlers/backfill.ts +16 -9
  202. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  203. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  204. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  205. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  206. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  207. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  208. package/src/memory/migrations/index.ts +5 -0
  209. package/src/memory/migrations/registry.ts +5 -0
  210. package/src/memory/qdrant-client.ts +31 -22
  211. package/src/memory/schema-migration.ts +1 -0
  212. package/src/memory/schema.ts +56 -0
  213. package/src/notifications/copy-composer.ts +31 -4
  214. package/src/notifications/decision-engine.ts +57 -0
  215. package/src/permissions/defaults.ts +2 -0
  216. package/src/runtime/access-request-helper.ts +173 -0
  217. package/src/runtime/actor-trust-resolver.ts +221 -0
  218. package/src/runtime/channel-guardian-service.ts +12 -4
  219. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  220. package/src/runtime/channel-retry-sweep.ts +18 -6
  221. package/src/runtime/guardian-context-resolver.ts +38 -71
  222. package/src/runtime/guardian-decision-types.ts +6 -0
  223. package/src/runtime/guardian-reply-router.ts +717 -0
  224. package/src/runtime/http-server.ts +8 -0
  225. package/src/runtime/ingress-service.ts +80 -3
  226. package/src/runtime/invite-redemption-service.ts +141 -2
  227. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  228. package/src/runtime/routes/channel-route-shared.ts +1 -1
  229. package/src/runtime/routes/channel-routes.ts +1 -1
  230. package/src/runtime/routes/conversation-routes.ts +20 -2
  231. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  232. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  233. package/src/runtime/routes/inbound-message-handler.ts +205 -529
  234. package/src/runtime/routes/ingress-routes.ts +52 -4
  235. package/src/runtime/routes/pairing-routes.ts +3 -0
  236. package/src/runtime/tool-grant-request-helper.ts +195 -0
  237. package/src/tools/executor.ts +13 -1
  238. package/src/tools/guardian-control-plane-policy.ts +2 -2
  239. package/src/tools/sensitive-output-placeholders.ts +203 -0
  240. package/src/tools/tool-approval-handler.ts +53 -10
  241. package/src/tools/types.ts +13 -2
  242. package/src/util/bundled-asset.ts +31 -0
  243. package/src/util/canonicalize-identity.ts +52 -0
  244. package/src/util/logger.ts +20 -8
  245. package/src/util/platform.ts +10 -0
  246. package/src/util/voice-code.ts +29 -0
  247. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -80,11 +80,12 @@ mock.module('../runtime/gateway-client.js', () => ({
80
80
  },
81
81
  }));
82
82
 
83
+ import { listCanonicalGuardianRequests } from '../memory/canonical-guardian-store.js';
83
84
  import {
84
85
  createBinding,
85
- findPendingAccessRequestForRequester,
86
86
  } from '../memory/channel-guardian-store.js';
87
87
  import { getDb, initializeDb, resetDb } from '../memory/db.js';
88
+ import { notifyGuardianOfAccessRequest } from '../runtime/access-request-helper.js';
88
89
  import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
89
90
 
90
91
  initializeDb();
@@ -107,6 +108,8 @@ function resetState(): void {
107
108
  db.run('DELETE FROM channel_inbound_events');
108
109
  db.run('DELETE FROM conversations');
109
110
  db.run('DELETE FROM notification_events');
111
+ db.run('DELETE FROM canonical_guardian_requests');
112
+ db.run('DELETE FROM canonical_guardian_deliveries');
110
113
  emitSignalCalls.length = 0;
111
114
  deliverReplyCalls.length = 0;
112
115
  }
@@ -152,9 +155,10 @@ describe('non-member access request notification', () => {
152
155
  expect(json.denied).toBe(true);
153
156
  expect(json.reason).toBe('not_a_member');
154
157
 
155
- // Rejection reply was delivered
158
+ // Rejection reply was delivered — always-notify behavior means the reply
159
+ // indicates the guardian will be notified, even without a same-channel binding.
156
160
  expect(deliverReplyCalls.length).toBe(1);
157
- expect((deliverReplyCalls[0].payload as Record<string, unknown>).text).toContain("you haven't been approved");
161
+ expect((deliverReplyCalls[0].payload as Record<string, unknown>).text).toContain("let them know");
158
162
  });
159
163
 
160
164
  test('guardian is notified when a non-member messages and a guardian binding exists', async () => {
@@ -185,18 +189,18 @@ describe('non-member access request notification', () => {
185
189
  expect(payload.senderExternalUserId).toBe('user-unknown-456');
186
190
  expect(payload.senderName).toBe('Alice Unknown');
187
191
 
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');
192
+ // A canonical access request was created
193
+ const pending = listCanonicalGuardianRequests({
194
+ status: 'pending',
195
+ requesterExternalUserId: 'user-unknown-456',
196
+ sourceChannel: 'telegram',
197
+ kind: 'access_request',
198
+ });
199
+ expect(pending.length).toBe(1);
200
+ expect(pending[0].status).toBe('pending');
201
+ expect(pending[0].requesterExternalUserId).toBe('user-unknown-456');
202
+ expect(pending[0].guardianExternalUserId).toBe('guardian-user-789');
203
+ expect(pending[0].toolName).toBe('ingress_access_request');
200
204
  });
201
205
 
202
206
  test('no duplicate approval requests for repeated messages from same non-member', async () => {
@@ -224,18 +228,19 @@ describe('non-member access request notification', () => {
224
228
  // Only one notification signal should be emitted (second is deduplicated)
225
229
  expect(emitSignalCalls.length).toBe(1);
226
230
 
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();
231
+ // Only one canonical request should exist
232
+ const pending = listCanonicalGuardianRequests({
233
+ status: 'pending',
234
+ requesterExternalUserId: 'user-unknown-456',
235
+ sourceChannel: 'telegram',
236
+ kind: 'access_request',
237
+ });
238
+ expect(pending.length).toBe(1);
235
239
  });
236
240
 
237
- test('deny works without error when no guardian binding exists', async () => {
238
- // No guardian binding — should deny without notification
241
+ test('access request is created and signal emitted even without same-channel guardian binding', async () => {
242
+ // No guardian binding on any channel access request should still be
243
+ // created and notification signal emitted (null guardianExternalUserId).
239
244
  const req = buildInboundRequest();
240
245
  const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
241
246
  const json = await resp.json() as Record<string, unknown>;
@@ -243,20 +248,55 @@ describe('non-member access request notification', () => {
243
248
  expect(json.denied).toBe(true);
244
249
  expect(json.reason).toBe('not_a_member');
245
250
 
246
- // Rejection reply was still delivered
251
+ // Rejection reply indicates guardian was notified
247
252
  expect(deliverReplyCalls.length).toBe(1);
253
+ expect((deliverReplyCalls[0].payload as Record<string, unknown>).text).toContain("let them know");
248
254
 
249
- // No notification signal was emitted
250
- expect(emitSignalCalls.length).toBe(0);
255
+ // Notification signal was emitted
256
+ expect(emitSignalCalls.length).toBe(1);
257
+ expect(emitSignalCalls[0].sourceEventName).toBe('ingress.access_request');
251
258
 
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();
259
+ // Canonical request was created with null guardianExternalUserId
260
+ const pending = listCanonicalGuardianRequests({
261
+ status: 'pending',
262
+ requesterExternalUserId: 'user-unknown-456',
263
+ sourceChannel: 'telegram',
264
+ kind: 'access_request',
265
+ });
266
+ expect(pending.length).toBe(1);
267
+ expect(pending[0].guardianExternalUserId).toBeNull();
268
+ });
269
+
270
+ test('cross-channel fallback: SMS guardian binding resolves for Telegram access request', async () => {
271
+ // Only an SMS guardian binding exists — no Telegram binding
272
+ createBinding({
273
+ assistantId: 'self',
274
+ channel: 'sms',
275
+ guardianExternalUserId: 'guardian-sms-user',
276
+ guardianDeliveryChatId: 'guardian-sms-chat',
277
+ });
278
+
279
+ const req = buildInboundRequest();
280
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
281
+ const json = await resp.json() as Record<string, unknown>;
282
+
283
+ expect(json.denied).toBe(true);
284
+ expect(json.reason).toBe('not_a_member');
285
+
286
+ // Notification signal emitted
287
+ expect(emitSignalCalls.length).toBe(1);
288
+ const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
289
+ expect(payload.guardianBindingChannel).toBe('sms');
290
+
291
+ // Canonical request has the SMS guardian's external user ID
292
+ const pending = listCanonicalGuardianRequests({
293
+ status: 'pending',
294
+ requesterExternalUserId: 'user-unknown-456',
295
+ sourceChannel: 'telegram',
296
+ kind: 'access_request',
297
+ });
298
+ expect(pending.length).toBe(1);
299
+ expect(pending[0].guardianExternalUserId).toBe('guardian-sms-user');
260
300
  });
261
301
 
262
302
  test('no notification when senderExternalUserId is absent', async () => {
@@ -279,3 +319,139 @@ describe('non-member access request notification', () => {
279
319
  expect(emitSignalCalls.length).toBe(0);
280
320
  });
281
321
  });
322
+
323
+ describe('access-request-helper unit tests', () => {
324
+ beforeEach(() => {
325
+ resetState();
326
+ });
327
+
328
+ test('notifyGuardianOfAccessRequest returns no_sender_id when senderExternalUserId is absent', () => {
329
+ const result = notifyGuardianOfAccessRequest({
330
+ canonicalAssistantId: 'self',
331
+ sourceChannel: 'telegram',
332
+ externalChatId: 'chat-123',
333
+ senderExternalUserId: undefined,
334
+ });
335
+
336
+ expect(result.notified).toBe(false);
337
+ if (!result.notified) {
338
+ expect(result.reason).toBe('no_sender_id');
339
+ }
340
+
341
+ // No canonical request created
342
+ const pending = listCanonicalGuardianRequests({ status: 'pending', kind: 'access_request' });
343
+ expect(pending.length).toBe(0);
344
+ });
345
+
346
+ test('notifyGuardianOfAccessRequest creates request with null guardianExternalUserId when no binding exists', () => {
347
+ const result = notifyGuardianOfAccessRequest({
348
+ canonicalAssistantId: 'self',
349
+ sourceChannel: 'telegram',
350
+ externalChatId: 'chat-123',
351
+ senderExternalUserId: 'unknown-user',
352
+ senderName: 'Bob',
353
+ });
354
+
355
+ expect(result.notified).toBe(true);
356
+ if (result.notified) {
357
+ expect(result.created).toBe(true);
358
+ }
359
+
360
+ const pending = listCanonicalGuardianRequests({
361
+ status: 'pending',
362
+ requesterExternalUserId: 'unknown-user',
363
+ kind: 'access_request',
364
+ });
365
+ expect(pending.length).toBe(1);
366
+ expect(pending[0].guardianExternalUserId).toBeNull();
367
+
368
+ // Signal was emitted
369
+ expect(emitSignalCalls.length).toBe(1);
370
+ });
371
+
372
+ test('notifyGuardianOfAccessRequest uses cross-channel binding when source-channel binding is missing', () => {
373
+ // Only SMS binding exists
374
+ createBinding({
375
+ assistantId: 'self',
376
+ channel: 'sms',
377
+ guardianExternalUserId: 'guardian-sms',
378
+ guardianDeliveryChatId: 'sms-chat',
379
+ });
380
+
381
+ const result = notifyGuardianOfAccessRequest({
382
+ canonicalAssistantId: 'self',
383
+ sourceChannel: 'telegram',
384
+ externalChatId: 'tg-chat',
385
+ senderExternalUserId: 'unknown-tg-user',
386
+ });
387
+
388
+ expect(result.notified).toBe(true);
389
+
390
+ const pending = listCanonicalGuardianRequests({
391
+ status: 'pending',
392
+ requesterExternalUserId: 'unknown-tg-user',
393
+ kind: 'access_request',
394
+ });
395
+ expect(pending.length).toBe(1);
396
+ expect(pending[0].guardianExternalUserId).toBe('guardian-sms');
397
+
398
+ // Signal payload includes fallback channel
399
+ const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
400
+ expect(payload.guardianBindingChannel).toBe('sms');
401
+ });
402
+
403
+ test('notifyGuardianOfAccessRequest prefers source-channel binding over cross-channel fallback', () => {
404
+ // Both Telegram and SMS bindings exist
405
+ createBinding({
406
+ assistantId: 'self',
407
+ channel: 'telegram',
408
+ guardianExternalUserId: 'guardian-tg',
409
+ guardianDeliveryChatId: 'tg-chat',
410
+ });
411
+ createBinding({
412
+ assistantId: 'self',
413
+ channel: 'sms',
414
+ guardianExternalUserId: 'guardian-sms',
415
+ guardianDeliveryChatId: 'sms-chat',
416
+ });
417
+
418
+ const result = notifyGuardianOfAccessRequest({
419
+ canonicalAssistantId: 'self',
420
+ sourceChannel: 'telegram',
421
+ externalChatId: 'chat-123',
422
+ senderExternalUserId: 'unknown-user',
423
+ });
424
+
425
+ expect(result.notified).toBe(true);
426
+
427
+ const pending = listCanonicalGuardianRequests({
428
+ status: 'pending',
429
+ requesterExternalUserId: 'unknown-user',
430
+ kind: 'access_request',
431
+ });
432
+ expect(pending.length).toBe(1);
433
+ // Should use the Telegram binding, not SMS fallback
434
+ expect(pending[0].guardianExternalUserId).toBe('guardian-tg');
435
+
436
+ const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
437
+ expect(payload.guardianBindingChannel).toBe('telegram');
438
+ });
439
+
440
+ test('notifyGuardianOfAccessRequest includes requestCode in contextPayload', () => {
441
+ const result = notifyGuardianOfAccessRequest({
442
+ canonicalAssistantId: 'self',
443
+ sourceChannel: 'telegram',
444
+ externalChatId: 'chat-123',
445
+ senderExternalUserId: 'unknown-user',
446
+ senderName: 'Test User',
447
+ });
448
+
449
+ expect(result.notified).toBe(true);
450
+ expect(emitSignalCalls.length).toBe(1);
451
+
452
+ const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
453
+ expect(payload.requestCode).toBeDefined();
454
+ expect(typeof payload.requestCode).toBe('string');
455
+ expect((payload.requestCode as string).length).toBe(6);
456
+ });
457
+ });
@@ -5,7 +5,7 @@
5
5
  * decision-model call is unavailable.
6
6
  */
7
7
 
8
- import { describe, expect, mock, test } from 'bun:test';
8
+ import { beforeEach, describe, expect, mock, test } from 'bun:test';
9
9
 
10
10
  mock.module('../channels/config.js', () => ({
11
11
  getDeliverableChannels: () => ['vellum', 'telegram', 'sms'],
@@ -13,6 +13,8 @@ mock.module('../channels/config.js', () => ({
13
13
 
14
14
  mock.module('../config/loader.js', () => ({
15
15
  getConfig: () => ({
16
+ ui: {},
17
+
16
18
  notifications: {
17
19
  decisionModelIntent: 'latency-optimized',
18
20
  },
@@ -32,13 +34,16 @@ mock.module('../notifications/thread-candidates.js', () => ({
32
34
  serializeCandidatesForPrompt: () => undefined,
33
35
  }));
34
36
 
37
+ let configuredProvider: { sendMessage: () => Promise<unknown> } | null = null;
38
+ let extractedToolUse: unknown = null;
39
+
35
40
  mock.module('../providers/provider-send-message.js', () => ({
36
- getConfiguredProvider: () => null,
41
+ getConfiguredProvider: () => configuredProvider,
37
42
  createTimeout: () => ({
38
43
  signal: new AbortController().signal,
39
44
  cleanup: () => {},
40
45
  }),
41
- extractToolUse: () => null,
46
+ extractToolUse: () => extractedToolUse,
42
47
  userMessage: (text: string) => ({ role: 'user', content: text }),
43
48
  }));
44
49
 
@@ -75,6 +80,11 @@ function makeSignal(overrides?: Partial<NotificationSignal>): NotificationSignal
75
80
  }
76
81
 
77
82
  describe('notification decision fallback copy', () => {
83
+ beforeEach(() => {
84
+ configuredProvider = null;
85
+ extractedToolUse = null;
86
+ });
87
+
78
88
  test('uses human-friendly template copy for guardian.question', async () => {
79
89
  const signal = makeSignal();
80
90
  const decision = await evaluateSignal(signal, ['vellum'] as NotificationChannel[]);
@@ -85,4 +95,54 @@ describe('notification decision fallback copy', () => {
85
95
  expect(decision.renderedCopy.vellum?.title).not.toBe('guardian.question');
86
96
  expect(decision.renderedCopy.vellum?.body).not.toContain('Action required: guardian.question');
87
97
  });
98
+
99
+ test('enforces request-code instructions for guardian.question when requestCode exists', async () => {
100
+ const signal = makeSignal({
101
+ contextPayload: {
102
+ questionText: 'What is the gate code?',
103
+ requestCode: 'A1B2C3',
104
+ },
105
+ });
106
+ const decision = await evaluateSignal(signal, ['vellum'] as NotificationChannel[]);
107
+
108
+ expect(decision.fallbackUsed).toBe(true);
109
+ expect(decision.renderedCopy.vellum?.body).toContain('A1B2C3');
110
+ expect(decision.renderedCopy.vellum?.body).toContain('approve');
111
+ expect(decision.renderedCopy.vellum?.body).toContain('reject');
112
+ });
113
+
114
+ test('enforcement appends explicit approve/reject instructions when LLM copy only mentions request code', async () => {
115
+ configuredProvider = {
116
+ sendMessage: async () => ({ content: [] }),
117
+ };
118
+ extractedToolUse = {
119
+ name: 'record_notification_decision',
120
+ input: {
121
+ shouldNotify: true,
122
+ selectedChannels: ['vellum'],
123
+ reasoningSummary: 'LLM decision',
124
+ renderedCopy: {
125
+ vellum: {
126
+ title: 'Guardian Question',
127
+ body: 'Use reference code A1B2C3 for this request.',
128
+ },
129
+ },
130
+ dedupeKey: 'guardian-question-test',
131
+ confidence: 0.9,
132
+ },
133
+ };
134
+
135
+ const signal = makeSignal({
136
+ contextPayload: {
137
+ questionText: 'What is the gate code?',
138
+ requestCode: 'A1B2C3',
139
+ },
140
+ });
141
+
142
+ const decision = await evaluateSignal(signal, ['vellum'] as NotificationChannel[]);
143
+
144
+ expect(decision.fallbackUsed).toBe(false);
145
+ expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 approve"');
146
+ expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 reject"');
147
+ });
88
148
  });
@@ -55,6 +55,23 @@ describe('notification decision strategy', () => {
55
55
  expect(copy.vellum!.body).toContain('What is the gate code?');
56
56
  });
57
57
 
58
+ test('guardian.question template includes request-code instructions when present', () => {
59
+ const signal = makeSignal({
60
+ sourceEventName: 'guardian.question',
61
+ contextPayload: {
62
+ questionText: 'What is the gate code?',
63
+ requestCode: 'A1B2C3',
64
+ },
65
+ });
66
+
67
+ const copy = composeFallbackCopy(signal, channels);
68
+ expect(copy.vellum).toBeDefined();
69
+ expect(copy.vellum!.body).toContain('A1B2C3');
70
+ expect(copy.vellum!.body).toContain('approve');
71
+ expect(copy.vellum!.body).toContain('reject');
72
+ expect(copy.telegram!.deliveryText).toContain('A1B2C3');
73
+ });
74
+
58
75
  test('reminder.fired template uses message from payload', () => {
59
76
  const signal = makeSignal({
60
77
  sourceEventName: 'reminder.fired',
@@ -134,6 +151,67 @@ describe('notification decision strategy', () => {
134
151
  expect(copy.vellum!.deliveryText).toBeUndefined();
135
152
  });
136
153
 
154
+ test('ingress.access_request template includes requester identifier', () => {
155
+ const signal = makeSignal({
156
+ sourceEventName: 'ingress.access_request',
157
+ contextPayload: {
158
+ senderIdentifier: 'Alice',
159
+ requestCode: 'A1B2C3',
160
+ },
161
+ });
162
+
163
+ const copy = composeFallbackCopy(signal, channels);
164
+ expect(copy.vellum).toBeDefined();
165
+ expect(copy.vellum!.title).toBe('Access Request');
166
+ expect(copy.vellum!.body).toContain('Alice');
167
+ expect(copy.vellum!.body).toContain('requesting access');
168
+ });
169
+
170
+ test('ingress.access_request template includes request code instruction when present', () => {
171
+ const signal = makeSignal({
172
+ sourceEventName: 'ingress.access_request',
173
+ contextPayload: {
174
+ senderIdentifier: 'Bob',
175
+ requestCode: 'D4E5F6',
176
+ },
177
+ });
178
+
179
+ const copy = composeFallbackCopy(signal, channels);
180
+ expect(copy.vellum).toBeDefined();
181
+ expect(copy.vellum!.body).toContain('D4E5F6');
182
+ expect(copy.vellum!.body).toContain('approve');
183
+ expect(copy.vellum!.body).toContain('reject');
184
+ });
185
+
186
+ test('ingress.access_request template includes invite flow instruction', () => {
187
+ const signal = makeSignal({
188
+ sourceEventName: 'ingress.access_request',
189
+ contextPayload: {
190
+ senderIdentifier: 'Charlie',
191
+ },
192
+ });
193
+
194
+ const copy = composeFallbackCopy(signal, channels);
195
+ expect(copy.vellum).toBeDefined();
196
+ expect(copy.vellum!.body).toContain('open invite flow');
197
+ });
198
+
199
+ test('ingress.access_request Telegram deliveryText is concise', () => {
200
+ const signal = makeSignal({
201
+ sourceEventName: 'ingress.access_request',
202
+ contextPayload: {
203
+ senderIdentifier: 'Dave',
204
+ requestCode: 'ABC123',
205
+ },
206
+ });
207
+
208
+ const copy = composeFallbackCopy(signal, ['telegram']);
209
+ expect(copy.telegram).toBeDefined();
210
+ expect(copy.telegram!.deliveryText).toBeDefined();
211
+ expect(typeof copy.telegram!.deliveryText).toBe('string');
212
+ expect(copy.telegram!.deliveryText!.length).toBeGreaterThan(0);
213
+ });
214
+
137
215
  test('empty payload falls back to default text in template', () => {
138
216
  const signal = makeSignal({
139
217
  sourceEventName: 'guardian.question',
@@ -47,6 +47,8 @@ mock.module('../memory/channel-guardian-store.js', () => ({
47
47
 
48
48
  mock.module('../config/loader.js', () => ({
49
49
  getConfig: () => ({
50
+ ui: {},
51
+
50
52
  calls: {
51
53
  userConsultTimeoutSeconds: 120,
52
54
  },
@@ -108,6 +110,8 @@ function ensureConversation(id: string): void {
108
110
 
109
111
  function resetTables(): void {
110
112
  const db = getDb();
113
+ db.run('DELETE FROM canonical_guardian_deliveries');
114
+ db.run('DELETE FROM canonical_guardian_requests');
111
115
  db.run('DELETE FROM guardian_action_deliveries');
112
116
  db.run('DELETE FROM guardian_action_requests');
113
117
  db.run('DELETE FROM call_pending_questions');
@@ -268,16 +272,15 @@ describe('ASK_GUARDIAN canonical notification path', () => {
268
272
 
269
273
  const db = getDb();
270
274
  const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
271
- const request = raw.query('SELECT * FROM guardian_action_requests WHERE call_session_id = ?').get(session.id) as
275
+ const request = raw.query('SELECT * FROM canonical_guardian_requests WHERE call_session_id = ?').get(session.id) as
272
276
  | { id: string }
273
277
  | undefined;
274
278
  const deliveries = raw.query(
275
- 'SELECT destination_channel, destination_conversation_id, destination_chat_id, destination_external_user_id, status FROM guardian_action_deliveries WHERE request_id = ? ORDER BY destination_channel ASC',
279
+ 'SELECT destination_channel, destination_conversation_id, destination_chat_id, status FROM canonical_guardian_deliveries WHERE request_id = ? ORDER BY destination_channel ASC',
276
280
  ).all(request!.id) as Array<{
277
281
  destination_channel: string;
278
282
  destination_conversation_id: string | null;
279
283
  destination_chat_id: string | null;
280
- destination_external_user_id: string | null;
281
284
  status: string;
282
285
  }>;
283
286
 
@@ -286,7 +289,6 @@ describe('ASK_GUARDIAN canonical notification path', () => {
286
289
  const vellum = deliveries.find((d) => d.destination_channel === 'vellum');
287
290
  expect(telegram).toBeDefined();
288
291
  expect(telegram!.destination_chat_id).toBe('tg-chat-abc');
289
- expect(telegram!.destination_external_user_id).toBe('tg-user-xyz');
290
292
  expect(telegram!.status).toBe('sent');
291
293
  expect(vellum).toBeDefined();
292
294
  expect(vellum!.destination_conversation_id).toBe('conv-guardian-vellum');
@@ -322,16 +324,15 @@ describe('ASK_GUARDIAN canonical notification path', () => {
322
324
 
323
325
  const db = getDb();
324
326
  const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
325
- const request = raw.query('SELECT * FROM guardian_action_requests WHERE call_session_id = ?').get(session.id) as
327
+ const request = raw.query('SELECT * FROM canonical_guardian_requests WHERE call_session_id = ?').get(session.id) as
326
328
  | { id: string }
327
329
  | undefined;
328
330
  const vellumDelivery = raw.query(
329
- 'SELECT status, last_error FROM guardian_action_deliveries WHERE request_id = ? AND destination_channel = ?',
330
- ).get(request!.id, 'vellum') as { status: string; last_error: string | null } | undefined;
331
+ 'SELECT status FROM canonical_guardian_deliveries WHERE request_id = ? AND destination_channel = ?',
332
+ ).get(request!.id, 'vellum') as { status: string } | undefined;
331
333
 
332
334
  expect(vellumDelivery).toBeDefined();
333
335
  expect(vellumDelivery!.status).toBe('failed');
334
- expect(vellumDelivery!.last_error).toContain('No vellum delivery result');
335
336
  });
336
337
 
337
338
  test('context payload includes callSessionId and activeGuardianRequestCount for candidate-affinity', async () => {
@@ -426,11 +427,11 @@ describe('ASK_GUARDIAN canonical notification path', () => {
426
427
  pendingQuestion: pq2,
427
428
  });
428
429
 
429
- // Verify: two distinct guardian_action_requests exist
430
+ // Verify: two distinct canonical_guardian_requests exist
430
431
  const db = getDb();
431
432
  const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
432
433
  const requests = raw.query(
433
- 'SELECT id, question_text FROM guardian_action_requests WHERE call_session_id = ? ORDER BY created_at ASC',
434
+ 'SELECT id, question_text FROM canonical_guardian_requests WHERE call_session_id = ? ORDER BY created_at ASC',
434
435
  ).all(session.id) as Array<{ id: string; question_text: string }>;
435
436
  expect(requests).toHaveLength(2);
436
437
  expect(requests[0].question_text).toBe('Can they enter through the side gate?');
@@ -438,7 +439,7 @@ describe('ASK_GUARDIAN canonical notification path', () => {
438
439
 
439
440
  // Verify: each request has its own delivery row pointing to the shared conversation
440
441
  const deliveries = raw.query(
441
- 'SELECT request_id, destination_conversation_id, status FROM guardian_action_deliveries WHERE destination_conversation_id = ? ORDER BY created_at ASC',
442
+ 'SELECT request_id, destination_conversation_id, status FROM canonical_guardian_deliveries WHERE destination_conversation_id = ? ORDER BY created_at ASC',
442
443
  ).all(sharedConvId) as Array<{ request_id: string; destination_conversation_id: string; status: string }>;
443
444
  expect(deliveries).toHaveLength(2);
444
445
  expect(deliveries[0].request_id).toBe(requests[0].id);
@@ -478,16 +479,15 @@ describe('ASK_GUARDIAN canonical notification path', () => {
478
479
  // The dispatch should still create a failed fallback delivery row
479
480
  const db = getDb();
480
481
  const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
481
- const request = raw.query('SELECT id FROM guardian_action_requests WHERE call_session_id = ?').get(session.id) as
482
+ const request = raw.query('SELECT id FROM canonical_guardian_requests WHERE call_session_id = ?').get(session.id) as
482
483
  | { id: string }
483
484
  | undefined;
484
485
  expect(request).toBeDefined();
485
486
 
486
487
  const delivery = raw.query(
487
- 'SELECT status, last_error FROM guardian_action_deliveries WHERE request_id = ? AND destination_channel = ?',
488
- ).get(request!.id, 'vellum') as { status: string; last_error: string | null } | undefined;
488
+ 'SELECT status FROM canonical_guardian_deliveries WHERE request_id = ? AND destination_channel = ?',
489
+ ).get(request!.id, 'vellum') as { status: string } | undefined;
489
490
  expect(delivery).toBeDefined();
490
491
  expect(delivery!.status).toBe('failed');
491
- expect(delivery!.last_error).toContain('No vellum delivery result');
492
492
  });
493
493
  });
@@ -39,7 +39,9 @@ mock.module('../util/logger.js', () => ({
39
39
  }));
40
40
 
41
41
  mock.module('../config/loader.js', () => ({
42
- getConfig: () => ({}),
42
+ getConfig: () => ({
43
+ ui: {},
44
+ }),
43
45
  loadConfig: () => ({ ingress: { publicBaseUrl: 'https://test.example.com' } }),
44
46
  loadRawConfig: () => ({}),
45
47
  saveRawConfig: () => {},
@@ -11,6 +11,8 @@ mock.module('../config/loader.js', () => ({
11
11
  ingress: { publicBaseUrl: mockPublicBaseUrl },
12
12
  }),
13
13
  getConfig: () => ({
14
+ ui: {},
15
+
14
16
  ingress: { publicBaseUrl: mockPublicBaseUrl },
15
17
  }),
16
18
  loadRawConfig: () => ({}),
@@ -151,7 +151,7 @@ describe('starter task playbook integration with buildSystemPrompt', () => {
151
151
  expect(result).not.toContain('## Starter Task Playbooks');
152
152
  });
153
153
 
154
- test('starter task playbook appears before channel awareness', () => {
154
+ test('starter task playbook and channel awareness both present during onboarding', () => {
155
155
  writeFileSync(join(TEST_DIR, 'IDENTITY.md'), 'I am Vellum.');
156
156
  writeFileSync(join(TEST_DIR, 'BOOTSTRAP.md'), '# First run');
157
157
  const result = buildSystemPrompt();
@@ -159,7 +159,6 @@ describe('starter task playbook integration with buildSystemPrompt', () => {
159
159
  const channelIdx = result.indexOf('## Channel Awareness & Trust Gating');
160
160
  expect(starterIdx).toBeGreaterThan(-1);
161
161
  expect(channelIdx).toBeGreaterThan(-1);
162
- expect(starterIdx).toBeLessThan(channelIdx);
163
162
  });
164
163
 
165
164
  test('all three kickoff intents present in full system prompt during onboarding', () => {
@@ -171,9 +170,10 @@ describe('starter task playbook integration with buildSystemPrompt', () => {
171
170
  expect(result).toContain('[STARTER_TASK:research_to_ui]');
172
171
  });
173
172
 
174
- test('system prompt does not contain invalid config_update surface type', () => {
173
+ test('system prompt does not contain invalid config_update surface type (bare)', () => {
175
174
  writeFileSync(join(TEST_DIR, 'IDENTITY.md'), 'I am Vellum.');
176
175
  const result = buildSystemPrompt();
177
- expect(result).not.toContain('config_update');
176
+ // voice_config_update is a valid tool name; only bare 'config_update' surface type is invalid
177
+ expect(result).not.toContain('surface_type: "config_update"');
178
178
  });
179
179
  });