@vellumai/assistant 0.3.28 → 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 (199) hide show
  1. package/ARCHITECTURE.md +33 -3
  2. package/bun.lock +4 -1
  3. package/docs/trusted-contact-access.md +9 -2
  4. package/package.json +6 -3
  5. package/scripts/ipc/generate-swift.ts +3 -3
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  7. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +13 -5
  9. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  10. package/src/__tests__/asset-search-tool.test.ts +2 -0
  11. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  12. package/src/__tests__/attachments-store.test.ts +2 -0
  13. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  14. package/src/__tests__/call-controller.test.ts +30 -29
  15. package/src/__tests__/call-routes-http.test.ts +34 -32
  16. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  17. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  18. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  19. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  20. package/src/__tests__/clarification-resolver.test.ts +2 -0
  21. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  22. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  24. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  25. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  26. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  27. package/src/__tests__/config-schema.test.ts +5 -5
  28. package/src/__tests__/config-watcher.test.ts +3 -1
  29. package/src/__tests__/connection-policy.test.ts +14 -5
  30. package/src/__tests__/contacts-tools.test.ts +3 -1
  31. package/src/__tests__/contradiction-checker.test.ts +2 -0
  32. package/src/__tests__/conversation-pairing.test.ts +10 -0
  33. package/src/__tests__/conversation-routes.test.ts +1 -1
  34. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  35. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  36. package/src/__tests__/credential-vault.test.ts +5 -4
  37. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  38. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  39. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  40. package/src/__tests__/encrypted-store.test.ts +10 -5
  41. package/src/__tests__/followup-tools.test.ts +3 -1
  42. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  43. package/src/__tests__/gmail-integration.test.ts +0 -1
  44. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  45. package/src/__tests__/guardian-dispatch.test.ts +2 -0
  46. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  47. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  48. package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
  49. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  50. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  51. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  52. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  53. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  54. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  55. package/src/__tests__/heartbeat-service.test.ts +20 -0
  56. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  57. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  58. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  59. package/src/__tests__/intent-routing.test.ts +2 -0
  60. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  61. package/src/__tests__/media-generate-image.test.ts +21 -0
  62. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  63. package/src/__tests__/memory-regressions.test.ts +20 -20
  64. package/src/__tests__/non-member-access-request.test.ts +183 -9
  65. package/src/__tests__/notification-decision-fallback.test.ts +2 -0
  66. package/src/__tests__/notification-decision-strategy.test.ts +61 -0
  67. package/src/__tests__/notification-guardian-path.test.ts +2 -0
  68. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  69. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  70. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  71. package/src/__tests__/pairing-routes.test.ts +171 -0
  72. package/src/__tests__/playbook-execution.test.ts +3 -1
  73. package/src/__tests__/playbook-tools.test.ts +3 -1
  74. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  75. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  76. package/src/__tests__/recording-handler.test.ts +11 -0
  77. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  78. package/src/__tests__/recording-state-machine.test.ts +13 -2
  79. package/src/__tests__/registry.test.ts +7 -3
  80. package/src/__tests__/relay-server.test.ts +148 -28
  81. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  82. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  83. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  84. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  85. package/src/__tests__/schedule-tools.test.ts +3 -1
  86. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  87. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  88. package/src/__tests__/session-agent-loop.test.ts +16 -0
  89. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  90. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  91. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  92. package/src/__tests__/session-profile-injection.test.ts +21 -0
  93. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  94. package/src/__tests__/session-queue.test.ts +23 -0
  95. package/src/__tests__/session-runtime-assembly.test.ts +50 -12
  96. package/src/__tests__/session-skill-tools.test.ts +27 -5
  97. package/src/__tests__/session-slash-known.test.ts +23 -0
  98. package/src/__tests__/session-slash-queue.test.ts +23 -0
  99. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  100. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  101. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  102. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  103. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  104. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  105. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  106. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  107. package/src/__tests__/skills.test.ts +8 -4
  108. package/src/__tests__/slack-channel-config.test.ts +3 -1
  109. package/src/__tests__/subagent-tools.test.ts +19 -0
  110. package/src/__tests__/swarm-recursion.test.ts +2 -0
  111. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  112. package/src/__tests__/swarm-tool.test.ts +2 -0
  113. package/src/__tests__/system-prompt.test.ts +3 -1
  114. package/src/__tests__/task-compiler.test.ts +3 -1
  115. package/src/__tests__/task-management-tools.test.ts +3 -1
  116. package/src/__tests__/task-tools.test.ts +3 -1
  117. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  118. package/src/__tests__/terminal-tools.test.ts +2 -0
  119. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  120. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  121. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  122. package/src/__tests__/tool-grant-request-escalation.test.ts +7 -7
  123. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  124. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  125. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  126. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  127. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  128. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  129. package/src/__tests__/view-image-tool.test.ts +3 -1
  130. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  131. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  132. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  133. package/src/__tests__/work-item-output.test.ts +3 -1
  134. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  135. package/src/calls/call-controller.ts +26 -23
  136. package/src/calls/guardian-action-sweep.ts +10 -2
  137. package/src/calls/relay-server.ts +216 -27
  138. package/src/calls/types.ts +1 -1
  139. package/src/calls/voice-session-bridge.ts +3 -3
  140. package/src/cli.ts +12 -0
  141. package/src/config/agent-schema.ts +14 -3
  142. package/src/config/calls-schema.ts +6 -6
  143. package/src/config/core-schema.ts +3 -3
  144. package/src/config/feature-flag-registry.json +8 -0
  145. package/src/config/mcp-schema.ts +1 -1
  146. package/src/config/memory-schema.ts +27 -19
  147. package/src/config/schema.ts +21 -21
  148. package/src/config/skills-schema.ts +7 -7
  149. package/src/config/vellum-skills/trusted-contacts/SKILL.md +139 -16
  150. package/src/daemon/handlers/config-inbox.ts +4 -4
  151. package/src/daemon/handlers/sessions.ts +148 -4
  152. package/src/daemon/ipc-contract/messages.ts +16 -0
  153. package/src/daemon/ipc-contract-inventory.json +1 -0
  154. package/src/daemon/lifecycle.ts +19 -0
  155. package/src/daemon/pairing-store.ts +86 -3
  156. package/src/daemon/session-agent-loop.ts +5 -5
  157. package/src/daemon/session-lifecycle.ts +25 -17
  158. package/src/daemon/session-memory.ts +2 -2
  159. package/src/daemon/session-process.ts +1 -20
  160. package/src/daemon/session-runtime-assembly.ts +28 -22
  161. package/src/daemon/session-tool-setup.ts +2 -2
  162. package/src/daemon/session.ts +3 -3
  163. package/src/memory/canonical-guardian-store.ts +63 -1
  164. package/src/memory/channel-guardian-store.ts +1 -0
  165. package/src/memory/conversation-crud.ts +7 -7
  166. package/src/memory/db-init.ts +4 -0
  167. package/src/memory/embedding-local.ts +257 -39
  168. package/src/memory/embedding-runtime-manager.ts +471 -0
  169. package/src/memory/guardian-bindings.ts +25 -1
  170. package/src/memory/indexer.ts +3 -3
  171. package/src/memory/ingress-invite-store.ts +45 -0
  172. package/src/memory/job-handlers/backfill.ts +16 -9
  173. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  174. package/src/memory/migrations/index.ts +1 -0
  175. package/src/memory/qdrant-client.ts +31 -22
  176. package/src/memory/schema.ts +4 -0
  177. package/src/notifications/copy-composer.ts +15 -0
  178. package/src/runtime/access-request-helper.ts +43 -7
  179. package/src/runtime/actor-trust-resolver.ts +46 -50
  180. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  181. package/src/runtime/channel-retry-sweep.ts +18 -6
  182. package/src/runtime/guardian-context-resolver.ts +38 -96
  183. package/src/runtime/guardian-reply-router.ts +31 -1
  184. package/src/runtime/ingress-service.ts +80 -3
  185. package/src/runtime/invite-redemption-service.ts +141 -2
  186. package/src/runtime/routes/channel-route-shared.ts +1 -1
  187. package/src/runtime/routes/channel-routes.ts +1 -1
  188. package/src/runtime/routes/conversation-routes.ts +2 -2
  189. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  190. package/src/runtime/routes/inbound-message-handler.ts +41 -10
  191. package/src/runtime/routes/ingress-routes.ts +52 -4
  192. package/src/runtime/routes/pairing-routes.ts +3 -0
  193. package/src/tools/guardian-control-plane-policy.ts +2 -2
  194. package/src/tools/tool-approval-handler.ts +11 -11
  195. package/src/tools/types.ts +2 -2
  196. package/src/util/logger.ts +20 -8
  197. package/src/util/platform.ts +10 -0
  198. package/src/util/voice-code.ts +29 -0
  199. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -85,6 +85,7 @@ import {
85
85
  createBinding,
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();
@@ -154,9 +155,10 @@ describe('non-member access request notification', () => {
154
155
  expect(json.denied).toBe(true);
155
156
  expect(json.reason).toBe('not_a_member');
156
157
 
157
- // 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.
158
160
  expect(deliverReplyCalls.length).toBe(1);
159
- 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");
160
162
  });
161
163
 
162
164
  test('guardian is notified when a non-member messages and a guardian binding exists', async () => {
@@ -236,8 +238,9 @@ describe('non-member access request notification', () => {
236
238
  expect(pending.length).toBe(1);
237
239
  });
238
240
 
239
- test('deny works without error when no guardian binding exists', async () => {
240
- // 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).
241
244
  const req = buildInboundRequest();
242
245
  const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
243
246
  const json = await resp.json() as Record<string, unknown>;
@@ -245,20 +248,55 @@ describe('non-member access request notification', () => {
245
248
  expect(json.denied).toBe(true);
246
249
  expect(json.reason).toBe('not_a_member');
247
250
 
248
- // Rejection reply was still delivered
251
+ // Rejection reply indicates guardian was notified
249
252
  expect(deliverReplyCalls.length).toBe(1);
253
+ expect((deliverReplyCalls[0].payload as Record<string, unknown>).text).toContain("let them know");
250
254
 
251
- // No notification signal was emitted
252
- 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');
253
258
 
254
- // No canonical request was created
259
+ // Canonical request was created with null guardianExternalUserId
255
260
  const pending = listCanonicalGuardianRequests({
256
261
  status: 'pending',
257
262
  requesterExternalUserId: 'user-unknown-456',
258
263
  sourceChannel: 'telegram',
259
264
  kind: 'access_request',
260
265
  });
261
- expect(pending.length).toBe(0);
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');
262
300
  });
263
301
 
264
302
  test('no notification when senderExternalUserId is absent', async () => {
@@ -281,3 +319,139 @@ describe('non-member access request notification', () => {
281
319
  expect(emitSignalCalls.length).toBe(0);
282
320
  });
283
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
+ });
@@ -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
  },
@@ -151,6 +151,67 @@ describe('notification decision strategy', () => {
151
151
  expect(copy.vellum!.deliveryText).toBeUndefined();
152
152
  });
153
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
+
154
215
  test('empty payload falls back to default text in template', () => {
155
216
  const signal = makeSignal({
156
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
  },
@@ -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
  });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * API-level tests for the device pairing routes.
3
+ *
4
+ * Validates that handlePairingRequest correctly prevents a second device
5
+ * from hijacking an existing pairing request, while allowing the same
6
+ * device to call the endpoint idempotently.
7
+ */
8
+
9
+ import { beforeEach, describe, expect, mock, test } from 'bun:test';
10
+
11
+ import { PairingStore } from '../daemon/pairing-store.js';
12
+ import type { PairingHandlerContext } from '../runtime/routes/pairing-routes.js';
13
+ import { handlePairingRequest } from '../runtime/routes/pairing-routes.js';
14
+
15
+ // ── Helpers ──────────────────────────────────────────────────────────────
16
+
17
+ const TEST_PAIRING_ID = 'pair-test-001';
18
+ const TEST_SECRET = 'super-secret-value';
19
+ const GATEWAY_URL = 'https://gateway.test';
20
+
21
+ function makeContext(store: PairingStore): PairingHandlerContext {
22
+ return {
23
+ pairingStore: store,
24
+ bearerToken: 'test-bearer-token',
25
+ featureFlagToken: undefined,
26
+ pairingBroadcast: mock(() => {}),
27
+ };
28
+ }
29
+
30
+ function makePairingRequest(overrides: Record<string, unknown> = {}): Request {
31
+ const body = {
32
+ pairingRequestId: TEST_PAIRING_ID,
33
+ pairingSecret: TEST_SECRET,
34
+ deviceId: 'device-A',
35
+ deviceName: 'iPhone A',
36
+ ...overrides,
37
+ };
38
+ return new Request('http://localhost/v1/pairing/request', {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify(body),
42
+ });
43
+ }
44
+
45
+ // ── Tests ────────────────────────────────────────────────────────────────
46
+
47
+ describe('handlePairingRequest — device binding', () => {
48
+ let store: PairingStore;
49
+ let ctx: PairingHandlerContext;
50
+
51
+ beforeEach(() => {
52
+ store = new PairingStore();
53
+ store.start();
54
+ ctx = makeContext(store);
55
+
56
+ // Pre-register the pairing request (simulating QR code display)
57
+ store.register({
58
+ pairingRequestId: TEST_PAIRING_ID,
59
+ pairingSecret: TEST_SECRET,
60
+ gatewayUrl: GATEWAY_URL,
61
+ });
62
+ });
63
+
64
+ test('rejects a second device attempting to pair with the same pairing ID', async () => {
65
+ /**
66
+ * Tests that once a device has initiated pairing, a different device
67
+ * cannot hijack the same pairing request.
68
+ */
69
+
70
+ // GIVEN device A has already initiated pairing
71
+ const firstReq = makePairingRequest({
72
+ deviceId: 'device-A',
73
+ deviceName: 'iPhone A',
74
+ });
75
+ const firstRes = await handlePairingRequest(firstReq, ctx);
76
+ expect(firstRes.status).toBe(200);
77
+
78
+ // WHEN device B tries to pair with the same pairing ID and secret
79
+ const secondReq = makePairingRequest({
80
+ deviceId: 'device-B',
81
+ deviceName: 'iPhone B',
82
+ });
83
+ const secondRes = await handlePairingRequest(secondReq, ctx);
84
+
85
+ // THEN the request is rejected with 409 Conflict
86
+ expect(secondRes.status).toBe(409);
87
+ const body = (await secondRes.json()) as { error: { code: string; message: string } };
88
+ expect(body.error.code).toBe('CONFLICT');
89
+ expect(body.error.message).toContain('already bound to another device');
90
+ });
91
+
92
+ test('allows the same device to call pairing request idempotently', async () => {
93
+ /**
94
+ * Tests that calling pairing request twice from the same device
95
+ * succeeds both times without error.
96
+ */
97
+
98
+ // GIVEN device A has already initiated pairing
99
+ const firstReq = makePairingRequest({
100
+ deviceId: 'device-A',
101
+ deviceName: 'iPhone A',
102
+ });
103
+ const firstRes = await handlePairingRequest(firstReq, ctx);
104
+ expect(firstRes.status).toBe(200);
105
+
106
+ // WHEN device A calls pairing request again with the same credentials
107
+ const secondReq = makePairingRequest({
108
+ deviceId: 'device-A',
109
+ deviceName: 'iPhone A',
110
+ });
111
+ const secondRes = await handlePairingRequest(secondReq, ctx);
112
+
113
+ // THEN it succeeds (idempotent)
114
+ expect(secondRes.status).toBe(200);
115
+ });
116
+
117
+ test('allows the same device to retrieve token after approval', async () => {
118
+ /**
119
+ * Tests that once a pairing request is approved, the same device
120
+ * can call the endpoint again and receive the bearer token.
121
+ */
122
+
123
+ // GIVEN device A has initiated pairing
124
+ const firstReq = makePairingRequest({
125
+ deviceId: 'device-A',
126
+ deviceName: 'iPhone A',
127
+ });
128
+ const firstRes = await handlePairingRequest(firstReq, ctx);
129
+ expect(firstRes.status).toBe(200);
130
+
131
+ // AND the pairing request has been approved
132
+ store.approve(TEST_PAIRING_ID, 'test-bearer-token');
133
+
134
+ // WHEN device A calls pairing request again
135
+ const secondReq = makePairingRequest({
136
+ deviceId: 'device-A',
137
+ deviceName: 'iPhone A',
138
+ });
139
+ const secondRes = await handlePairingRequest(secondReq, ctx);
140
+
141
+ // THEN the request succeeds (status stays approved, device matches)
142
+ expect(secondRes.status).toBe(200);
143
+ });
144
+
145
+ test('rejects a different device even after the first device was approved', async () => {
146
+ /**
147
+ * Tests that a different device cannot hijack a pairing request
148
+ * even after the original device's request has been approved.
149
+ */
150
+
151
+ // GIVEN device A has paired and been approved
152
+ const firstReq = makePairingRequest({
153
+ deviceId: 'device-A',
154
+ deviceName: 'iPhone A',
155
+ });
156
+ await handlePairingRequest(firstReq, ctx);
157
+ store.approve(TEST_PAIRING_ID, 'test-bearer-token');
158
+
159
+ // WHEN device B tries to use the same pairing request
160
+ const hijackReq = makePairingRequest({
161
+ deviceId: 'device-B',
162
+ deviceName: 'Attacker Phone',
163
+ });
164
+ const hijackRes = await handlePairingRequest(hijackReq, ctx);
165
+
166
+ // THEN it is rejected
167
+ expect(hijackRes.status).toBe(409);
168
+ const body = (await hijackRes.json()) as { error: { code: string; message: string } };
169
+ expect(body.error.code).toBe('CONFLICT');
170
+ });
171
+ });
@@ -27,7 +27,9 @@ mock.module('../util/logger.js', () => ({
27
27
  }));
28
28
 
29
29
  mock.module('../config/loader.js', () => ({
30
- getConfig: () => ({ memory: {} }),
30
+ getConfig: () => ({
31
+ ui: {},
32
+ memory: {} }),
31
33
  }));
32
34
 
33
35
  mock.module('../memory/jobs-store.js', () => ({
@@ -27,7 +27,9 @@ mock.module('../util/logger.js', () => ({
27
27
  }));
28
28
 
29
29
  mock.module('../config/loader.js', () => ({
30
- getConfig: () => ({ memory: {} }),
30
+ getConfig: () => ({
31
+ ui: {},
32
+ memory: {} }),
31
33
  }));
32
34
 
33
35
  // Stub memory job queue to avoid side effects
@@ -1,21 +1,72 @@
1
- import { resolve } from 'node:path';
2
-
3
1
  import { describe, expect, mock,test } from 'bun:test';
4
2
 
5
- const retryModulePath = resolve(import.meta.dir, '../util/retry.ts');
6
-
7
3
  mock.module('../util/logger.js', () => ({
8
4
  getLogger: () =>
9
5
  new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
10
6
  isDebug: () => false,
11
7
  }));
12
8
 
13
- // Only mock sleep so retries complete instantly; keep real retry logic
14
- mock.module('../util/retry.js', async () => {
15
- const real = await import(retryModulePath);
9
+ // Only mock sleep so retries complete instantly; keep real retry logic.
10
+ // NOTE: We must NOT use `await import()` inside mock.module it deadlocks
11
+ // bun's module resolver. Instead, inline the real exports and only replace sleep.
12
+ mock.module('../util/retry.js', () => {
13
+ const DEFAULT_MAX_RETRIES = 3;
14
+ const DEFAULT_BASE_DELAY_MS = 1000;
15
+
16
+ function computeRetryDelay(attempt: number, baseDelayMs = DEFAULT_BASE_DELAY_MS): number {
17
+ const cap = baseDelayMs * Math.pow(2, attempt);
18
+ const half = cap / 2;
19
+ return half + Math.random() * half;
20
+ }
21
+
22
+ function parseRetryAfterMs(value: string): number | undefined {
23
+ const seconds = Number(value);
24
+ if (!isNaN(seconds)) return seconds * 1000;
25
+ const dateMs = Date.parse(value);
26
+ if (!isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
27
+ return undefined;
28
+ }
29
+
30
+ function getHttpRetryDelay(
31
+ response: Response,
32
+ attempt: number,
33
+ baseDelayMs = DEFAULT_BASE_DELAY_MS,
34
+ ): number {
35
+ const retryAfter = response.headers.get('retry-after');
36
+ if (retryAfter) {
37
+ const parsed = parseRetryAfterMs(retryAfter);
38
+ if (parsed !== undefined) return parsed;
39
+ }
40
+ const effectiveBase = attempt === 0 ? baseDelayMs * 2 : baseDelayMs;
41
+ return Math.max(baseDelayMs, computeRetryDelay(attempt, effectiveBase));
42
+ }
43
+
44
+ function isRetryableStatus(status: number): boolean {
45
+ return status === 429 || status >= 500;
46
+ }
47
+
48
+ function isRetryableNetworkError(error: unknown): boolean {
49
+ if (!(error instanceof Error)) return false;
50
+ const retryableCodes = new Set(['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE']);
51
+ const code = (error as NodeJS.ErrnoException).code;
52
+ if (code && retryableCodes.has(code)) return true;
53
+ if (error.cause instanceof Error) {
54
+ const causeCode = (error.cause as NodeJS.ErrnoException).code;
55
+ if (causeCode && retryableCodes.has(causeCode)) return true;
56
+ }
57
+ return false;
58
+ }
59
+
16
60
  return {
17
- ...real,
61
+ DEFAULT_MAX_RETRIES,
62
+ DEFAULT_BASE_DELAY_MS,
63
+ computeRetryDelay,
64
+ parseRetryAfterMs,
65
+ getHttpRetryDelay,
66
+ isRetryableStatus,
67
+ isRetryableNetworkError,
18
68
  sleep: () => Promise.resolve(),
69
+ abortableSleep: () => Promise.resolve(),
19
70
  };
20
71
  });
21
72
 
@@ -17,6 +17,8 @@ mock.module('../permissions/trust-store.js', () => ({
17
17
 
18
18
  mock.module('../config/loader.js', () => ({
19
19
  getConfig: () => ({
20
+ ui: {},
21
+
20
22
  provider: 'mock-provider',
21
23
  timeouts: { permissionTimeoutSec: 5 },
22
24
  permissions: { mode: 'legacy' },
@@ -17,6 +17,8 @@ mock.module('../util/logger.js', () => ({
17
17
 
18
18
  mock.module('../config/loader.js', () => ({
19
19
  getConfig: () => ({
20
+ ui: {},
21
+
20
22
  daemon: { standaloneRecording: true },
21
23
  provider: 'mock-provider',
22
24
  permissions: { mode: 'legacy' },
@@ -49,6 +51,15 @@ const mockMessages: Array<{ id: string; role: string; content: string }> = [];
49
51
  let mockMessageIdCounter = 0;
50
52
 
51
53
  mock.module('../memory/conversation-store.js', () => ({
54
+ getConversationThreadType: () => 'default',
55
+ setConversationOriginChannelIfUnset: () => {},
56
+ updateConversationContextWindow: () => {},
57
+ deleteMessageById: () => {},
58
+ updateConversationTitle: () => {},
59
+ updateConversationUsage: () => {},
60
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
61
+ getConversationOriginInterface: () => null,
62
+ getConversationOriginChannel: () => null,
52
63
  getMessages: () => mockMessages,
53
64
  addMessage: (_convId: string, role: string, content: string) => {
54
65
  const msg = { id: `msg-${++mockMessageIdCounter}`, role, content };