@vellumai/assistant 0.4.35 → 0.4.37

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 (239) hide show
  1. package/AGENTS.md +1 -1
  2. package/ARCHITECTURE.md +44 -49
  3. package/README.md +32 -20
  4. package/docs/architecture/keychain-broker.md +186 -0
  5. package/docs/architecture/security.md +110 -116
  6. package/docs/runbook-trusted-contacts.md +2 -2
  7. package/docs/skills.md +25 -25
  8. package/package.json +5 -2
  9. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +11 -2
  10. package/src/__tests__/actor-token-service.test.ts +1 -0
  11. package/src/__tests__/amazon-cdp-integration.test.ts +74 -0
  12. package/src/__tests__/assistant-feature-flags-integration.test.ts +38 -9
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +29 -0
  14. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  15. package/src/__tests__/bundle-scanner.test.ts +1 -1
  16. package/src/__tests__/channel-guardian.test.ts +102 -102
  17. package/src/__tests__/channel-invite-transport.test.ts +155 -256
  18. package/src/__tests__/channel-readiness-routes.test.ts +336 -0
  19. package/src/__tests__/checker.test.ts +6 -6
  20. package/src/__tests__/chrome-cdp.test.ts +350 -0
  21. package/src/__tests__/computer-use-session-lifecycle.test.ts +3 -3
  22. package/src/__tests__/computer-use-session-working-dir.test.ts +86 -52
  23. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +1 -1
  24. package/src/__tests__/config-loader-migration.test.ts +85 -0
  25. package/src/__tests__/conversation-pairing.test.ts +370 -5
  26. package/src/__tests__/credential-broker-browser-fill.test.ts +1 -10
  27. package/src/__tests__/credential-broker-server-use.test.ts +1 -10
  28. package/src/__tests__/credential-security-e2e.test.ts +7 -1
  29. package/src/__tests__/credential-security-invariants.test.ts +14 -20
  30. package/src/__tests__/credential-vault-unit.test.ts +1 -11
  31. package/src/__tests__/credential-vault.test.ts +5 -19
  32. package/src/__tests__/credentials-cli.test.ts +814 -0
  33. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +23 -4
  34. package/src/__tests__/email-invite-adapter.test.ts +78 -0
  35. package/src/__tests__/email-service-config-fallback.test.ts +102 -0
  36. package/src/__tests__/encrypted-store.test.ts +6 -6
  37. package/src/__tests__/ephemeral-permissions.test.ts +3 -3
  38. package/src/__tests__/gateway-only-enforcement.test.ts +5 -1
  39. package/src/__tests__/guardian-actions-endpoint.test.ts +70 -12
  40. package/src/__tests__/guardian-outbound-http.test.ts +53 -47
  41. package/src/__tests__/handle-user-message-secret-resume.test.ts +23 -0
  42. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +32 -23
  43. package/src/__tests__/handlers-telegram-config.test.ts +8 -2
  44. package/src/__tests__/handlers-twitter-config.test.ts +2 -2
  45. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +108 -7
  46. package/src/__tests__/ingress-reconcile.test.ts +6 -0
  47. package/src/__tests__/intent-routing.test.ts +23 -4
  48. package/src/__tests__/invite-routes-http.test.ts +12 -0
  49. package/src/__tests__/ipc-snapshot.test.ts +8 -2
  50. package/src/__tests__/keychain-broker-client.test.ts +543 -0
  51. package/src/__tests__/llm-usage-store.test.ts +344 -0
  52. package/src/__tests__/mcp-client-auth.test.ts +2 -2
  53. package/src/__tests__/media-reuse-story.e2e.test.ts +1 -1
  54. package/src/__tests__/migration-transport.test.ts +49 -0
  55. package/src/__tests__/notification-broadcaster.test.ts +205 -5
  56. package/src/__tests__/notification-deep-link.test.ts +365 -1
  57. package/src/__tests__/oauth-connect-handler.test.ts +2 -2
  58. package/src/__tests__/onboarding-starter-tasks.test.ts +17 -4
  59. package/src/__tests__/proxy-approval-callback.test.ts +1 -1
  60. package/src/__tests__/recording-handler.test.ts +1 -1
  61. package/src/__tests__/recording-intent-handler.test.ts +6 -1
  62. package/src/__tests__/recording-state-machine.test.ts +1 -1
  63. package/src/__tests__/relay-server.test.ts +9 -1
  64. package/src/__tests__/ride-shotgun-handler.test.ts +499 -0
  65. package/src/__tests__/runtime-attachment-metadata.test.ts +160 -1
  66. package/src/__tests__/script-proxy-injection-runtime.test.ts +299 -2
  67. package/src/__tests__/script-proxy-profile-template-fallback.test.ts +1 -1
  68. package/src/__tests__/secret-onetime-send.test.ts +8 -2
  69. package/src/__tests__/secure-keys.test.ts +175 -216
  70. package/src/__tests__/session-confirmation-signals.test.ts +1 -1
  71. package/src/__tests__/session-messaging-secret-redirect.test.ts +1 -1
  72. package/src/__tests__/session-queue.test.ts +2 -1
  73. package/src/__tests__/session-tool-setup-app-refresh.test.ts +2 -2
  74. package/src/__tests__/skill-feature-flags-integration.test.ts +29 -4
  75. package/src/__tests__/skill-feature-flags.test.ts +12 -9
  76. package/src/__tests__/skill-load-feature-flag.test.ts +26 -5
  77. package/src/__tests__/skill-projection.benchmark.test.ts +0 -1
  78. package/src/__tests__/skills.test.ts +34 -4
  79. package/src/__tests__/slack-channel-config.test.ts +2 -2
  80. package/src/__tests__/system-prompt.test.ts +26 -4
  81. package/src/__tests__/telegram-bot-username-resolution.test.ts +212 -0
  82. package/src/__tests__/telegram-invite-adapter.test.ts +164 -0
  83. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -1
  84. package/src/__tests__/tool-permission-simulate-handler.test.ts +8 -2
  85. package/src/__tests__/trusted-contact-approval-notifier.test.ts +9 -1
  86. package/src/__tests__/twitter-auth-handler.test.ts +2 -2
  87. package/src/__tests__/twitter-oauth-client.test.ts +1 -1
  88. package/src/__tests__/usage-routes.test.ts +339 -0
  89. package/src/__tests__/whatsapp-invite-adapter.test.ts +94 -0
  90. package/src/agent/loop.ts +3 -0
  91. package/src/amazon/checkout.ts +0 -1
  92. package/src/approvals/guardian-request-resolvers.ts +9 -1
  93. package/src/bundler/app-bundler.ts +28 -12
  94. package/src/bundler/bundle-scanner.ts +1 -1
  95. package/src/bundler/bundle-signer.ts +3 -3
  96. package/src/bundler/manifest.ts +1 -1
  97. package/src/bundler/signature-verifier.ts +3 -3
  98. package/src/channels/config.ts +1 -1
  99. package/src/cli/AGENTS.md +63 -0
  100. package/src/cli/__tests__/notifications.test.ts +470 -0
  101. package/src/cli/amazon.ts +344 -167
  102. package/src/cli/audit.ts +85 -0
  103. package/src/cli/autonomy.ts +369 -0
  104. package/src/cli/channels.ts +51 -0
  105. package/src/cli/completions.ts +208 -0
  106. package/src/cli/config.ts +220 -0
  107. package/src/cli/contacts.ts +471 -0
  108. package/src/cli/credentials.ts +564 -0
  109. package/src/cli/default-action.ts +14 -0
  110. package/src/cli/dev.ts +131 -0
  111. package/src/cli/doctor.ts +398 -0
  112. package/src/cli/email.ts +494 -0
  113. package/src/cli/influencer.ts +72 -0
  114. package/src/cli/integrations.ts +248 -57
  115. package/src/cli/keys.ts +114 -0
  116. package/src/cli/map.ts +46 -54
  117. package/src/cli/mcp.ts +111 -3
  118. package/src/cli/{config-commands.ts → memory.ts} +134 -245
  119. package/src/cli/notifications.ts +407 -0
  120. package/src/cli/program.ts +65 -0
  121. package/src/cli/reference.ts +48 -0
  122. package/src/cli/sequence.ts +154 -0
  123. package/src/cli/sessions.ts +262 -0
  124. package/src/cli/trust.ts +175 -0
  125. package/src/cli/twitter.ts +323 -106
  126. package/src/config/__tests__/build-cli-reference-section.test.ts +49 -0
  127. package/src/config/bundled-skills/amazon/SKILL.md +2 -2
  128. package/src/config/bundled-skills/app-builder/TOOLS.json +26 -0
  129. package/src/config/bundled-skills/app-builder/tools/app-generate-icon.ts +13 -0
  130. package/src/config/bundled-skills/contacts/SKILL.md +178 -10
  131. package/src/config/bundled-skills/doordash/doordash-cli.ts +23 -168
  132. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +135 -34
  133. package/src/config/bundled-skills/messaging/tools/shared.ts +4 -1
  134. package/src/config/bundled-skills/twilio-setup/SKILL.md +70 -17
  135. package/src/config/bundled-tool-registry.ts +2 -0
  136. package/src/config/core-schema.ts +7 -0
  137. package/src/config/feature-flag-registry.json +16 -0
  138. package/src/config/loader.ts +26 -0
  139. package/src/config/schema.ts +4 -0
  140. package/src/config/skill-state.ts +0 -13
  141. package/src/config/system-prompt.ts +27 -0
  142. package/src/contacts/contact-store.ts +25 -0
  143. package/src/daemon/computer-use-session.ts +1 -1
  144. package/src/daemon/handlers/apps.ts +1 -0
  145. package/src/daemon/handlers/config-channels.ts +3 -3
  146. package/src/daemon/handlers/config-dispatch.ts +29 -0
  147. package/src/daemon/handlers/config-inbox.ts +4 -3
  148. package/src/daemon/handlers/config.ts +3 -43
  149. package/src/daemon/handlers/contacts.ts +34 -0
  150. package/src/daemon/handlers/index.ts +17 -3
  151. package/src/daemon/handlers/session-user-message.ts +7 -0
  152. package/src/daemon/handlers/sessions.ts +21 -2
  153. package/src/daemon/handlers/shared.ts +17 -0
  154. package/src/daemon/ipc-contract/apps.ts +2 -0
  155. package/src/daemon/ipc-contract/computer-use.ts +9 -0
  156. package/src/daemon/ipc-contract/contacts.ts +3 -3
  157. package/src/daemon/ipc-contract/inbox.ts +2 -0
  158. package/src/daemon/ipc-contract/messages.ts +4 -0
  159. package/src/daemon/ipc-contract/sessions.ts +8 -0
  160. package/src/daemon/ipc-contract-inventory.json +1 -0
  161. package/src/daemon/lifecycle.ts +0 -5
  162. package/src/daemon/ride-shotgun-handler.ts +139 -25
  163. package/src/daemon/session-agent-loop-handlers.ts +100 -0
  164. package/src/daemon/session-agent-loop.ts +72 -0
  165. package/src/daemon/session-tool-setup.ts +7 -0
  166. package/src/daemon/session.ts +23 -1
  167. package/src/daemon/tool-side-effects.ts +39 -1
  168. package/src/email/service.ts +59 -2
  169. package/src/index.ts +2 -60
  170. package/src/mcp/mcp-oauth-provider.ts +90 -8
  171. package/src/media/app-icon-generator.ts +86 -0
  172. package/src/memory/db-init.ts +11 -0
  173. package/src/memory/llm-usage-store.ts +186 -0
  174. package/src/memory/migrations/137-usage-dashboard-indexes.ts +26 -0
  175. package/src/memory/migrations/139-drop-usage-composite-indexes.ts +30 -0
  176. package/src/memory/migrations/index.ts +2 -0
  177. package/src/memory/schema-migration.ts +1 -0
  178. package/src/memory/shared-app-links-store.ts +1 -1
  179. package/src/messaging/registry.ts +27 -0
  180. package/src/notifications/README.md +79 -70
  181. package/src/notifications/broadcaster.ts +2 -1
  182. package/src/notifications/conversation-pairing.ts +147 -13
  183. package/src/notifications/copy-composer.ts +7 -3
  184. package/src/notifications/destination-resolver.ts +14 -1
  185. package/src/notifications/emit-signal.ts +3 -2
  186. package/src/notifications/signal.ts +105 -1
  187. package/src/notifications/types.ts +16 -0
  188. package/src/permissions/checker.ts +29 -3
  189. package/src/permissions/prompter.ts +11 -3
  190. package/src/runtime/access-request-helper.ts +2 -1
  191. package/src/runtime/auth/route-policy.ts +7 -1
  192. package/src/runtime/channel-invite-transport.ts +40 -63
  193. package/src/runtime/channel-invite-transports/email.ts +13 -39
  194. package/src/runtime/channel-invite-transports/slack.ts +5 -34
  195. package/src/runtime/channel-invite-transports/sms.ts +8 -29
  196. package/src/runtime/channel-invite-transports/telegram.ts +69 -28
  197. package/src/runtime/channel-invite-transports/voice.ts +0 -7
  198. package/src/runtime/channel-invite-transports/whatsapp.ts +43 -0
  199. package/src/runtime/channel-readiness-service.ts +202 -45
  200. package/src/runtime/confirmation-request-guardian-bridge.ts +2 -1
  201. package/src/runtime/guardian-outbound-actions.ts +8 -5
  202. package/src/runtime/http-server.ts +2 -0
  203. package/src/runtime/invite-instruction-generator.ts +178 -0
  204. package/src/runtime/invite-service.ts +22 -25
  205. package/src/runtime/migrations/migration-transport.ts +13 -0
  206. package/src/runtime/routes/app-routes.ts +1 -1
  207. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +8 -7
  208. package/src/runtime/routes/channel-readiness-routes.ts +30 -11
  209. package/src/runtime/routes/contact-routes.ts +54 -26
  210. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +1 -1
  211. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +2 -1
  212. package/src/runtime/routes/inbound-stages/verification-intercept.ts +2 -1
  213. package/src/runtime/routes/integration-routes.ts +1 -1
  214. package/src/runtime/routes/invite-routes.ts +1 -1
  215. package/src/runtime/routes/secret-routes.ts +31 -7
  216. package/src/runtime/routes/twilio-routes.ts +32 -1
  217. package/src/runtime/routes/usage-routes.ts +114 -0
  218. package/src/runtime/tool-grant-request-helper.ts +2 -1
  219. package/src/security/encrypted-store.ts +9 -5
  220. package/src/security/keychain-broker-client.ts +393 -0
  221. package/src/security/secure-keys.ts +106 -321
  222. package/src/tools/apps/executors.ts +73 -0
  223. package/src/tools/browser/auto-navigate.ts +15 -6
  224. package/src/tools/browser/chrome-cdp.ts +211 -0
  225. package/src/tools/browser/network-recorder.test.ts +83 -0
  226. package/src/tools/browser/network-recorder.ts +8 -7
  227. package/src/tools/browser/x-auto-navigate.ts +12 -6
  228. package/src/tools/credentials/policy-types.ts +24 -0
  229. package/src/tools/credentials/vault.ts +22 -27
  230. package/src/tools/network/script-proxy/session-manager.ts +47 -3
  231. package/src/tools/permission-checker.ts +1 -0
  232. package/src/tools/types.ts +2 -0
  233. package/src/tools/ui-surface/definitions.ts +1 -2
  234. package/src/tools/watch/watch-state.ts +2 -0
  235. package/src/__tests__/key-migration.test.ts +0 -240
  236. package/src/__tests__/keychain.test.ts +0 -286
  237. package/src/cli/core-commands.ts +0 -899
  238. package/src/security/keychain-to-encrypted-migration.ts +0 -66
  239. package/src/security/keychain.ts +0 -490
@@ -7,7 +7,9 @@
7
7
  * the conversation was newly created or reused.
8
8
  */
9
9
 
10
- import { describe, expect, mock, test } from "bun:test";
10
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
11
+
12
+ import type { PairingOptions } from "../notifications/conversation-pairing.js";
11
13
 
12
14
  // -- Mocks (must be declared before importing modules that depend on them) ----
13
15
 
@@ -18,12 +20,129 @@ mock.module("../util/logger.js", () => ({
18
20
  }),
19
21
  }));
20
22
 
23
+ // Mock destination-resolver for broadcaster tests
24
+ mock.module("../notifications/destination-resolver.js", () => ({
25
+ resolveDestinations: (channels: string[]) => {
26
+ const m = new Map();
27
+ for (const ch of channels) {
28
+ m.set(ch, { channel: ch, endpoint: `mock-${ch}` });
29
+ }
30
+ return m;
31
+ },
32
+ }));
33
+
34
+ // Mock deliveries-store to avoid DB access
35
+ mock.module("../notifications/deliveries-store.js", () => ({
36
+ createDelivery: () => {},
37
+ updateDeliveryStatus: () => {},
38
+ findDeliveryByDecisionAndChannel: () => undefined,
39
+ }));
40
+
41
+ // Configurable mock for conversation-pairing
42
+ let nextPairingResult:
43
+ | import("../notifications/conversation-pairing.js").PairingResult
44
+ | null = null;
45
+ let pairingCallCount = 0;
46
+
47
+ mock.module("../notifications/conversation-pairing.js", () => ({
48
+ pairDeliveryWithConversation: async (
49
+ _signal: unknown,
50
+ _channel: string,
51
+ _copy: unknown,
52
+ _options?: PairingOptions,
53
+ ) => {
54
+ if (nextPairingResult) {
55
+ const result = nextPairingResult;
56
+ nextPairingResult = null;
57
+ return result;
58
+ }
59
+ const id = `mock-conv-${++pairingCallCount}`;
60
+ return {
61
+ conversationId: id,
62
+ messageId: `mock-msg-${pairingCallCount}`,
63
+ strategy: "start_new_conversation" as const,
64
+ createdNewConversation: true,
65
+ threadDecisionFallbackUsed: false,
66
+ };
67
+ },
68
+ }));
69
+
21
70
  import type { ServerMessage } from "../daemon/ipc-contract.js";
22
71
  import { VellumAdapter } from "../notifications/adapters/macos.js";
72
+ import { NotificationBroadcaster } from "../notifications/broadcaster.js";
73
+ import type { NotificationSignal } from "../notifications/signal.js";
74
+ import type {
75
+ ChannelAdapter,
76
+ ChannelDeliveryPayload,
77
+ ChannelDestination,
78
+ DeliveryResult,
79
+ NotificationChannel,
80
+ NotificationDecision,
81
+ } from "../notifications/types.js";
82
+
83
+ // -- Helpers -----------------------------------------------------------------
84
+
85
+ function makeSignal(
86
+ overrides?: Partial<NotificationSignal>,
87
+ ): NotificationSignal {
88
+ return {
89
+ signalId: "sig-deeplink-001",
90
+ createdAt: Date.now(),
91
+ sourceChannel: "scheduler",
92
+ sourceSessionId: "sess-001",
93
+ sourceEventName: "test.event",
94
+ contextPayload: {},
95
+ attentionHints: {
96
+ requiresAction: false,
97
+ urgency: "medium",
98
+ isAsyncBackground: true,
99
+ visibleInSourceNow: false,
100
+ },
101
+ ...overrides,
102
+ };
103
+ }
104
+
105
+ function makeDecision(
106
+ overrides?: Partial<NotificationDecision>,
107
+ ): NotificationDecision {
108
+ return {
109
+ shouldNotify: true,
110
+ selectedChannels: ["vellum"],
111
+ reasoningSummary: "Deep-link test decision",
112
+ renderedCopy: {
113
+ vellum: { title: "Test Alert", body: "Something happened" },
114
+ },
115
+ dedupeKey: "deeplink-test-001",
116
+ confidence: 0.9,
117
+ fallbackUsed: false,
118
+ ...overrides,
119
+ };
120
+ }
121
+
122
+ class MockAdapter implements ChannelAdapter {
123
+ readonly channel: NotificationChannel;
124
+ sent: ChannelDeliveryPayload[] = [];
125
+
126
+ constructor(channel: NotificationChannel) {
127
+ this.channel = channel;
128
+ }
129
+
130
+ async send(
131
+ payload: ChannelDeliveryPayload,
132
+ _dest: ChannelDestination,
133
+ ): Promise<DeliveryResult> {
134
+ this.sent.push(payload);
135
+ return { success: true };
136
+ }
137
+ }
23
138
 
24
139
  // -- Tests -------------------------------------------------------------------
25
140
 
26
141
  describe("notification deep-link metadata", () => {
142
+ beforeEach(() => {
143
+ nextPairingResult = null;
144
+ });
145
+
27
146
  describe("VellumAdapter", () => {
28
147
  test("broadcasts notification_intent with deepLinkMetadata from payload", async () => {
29
148
  const messages: ServerMessage[] = [];
@@ -221,5 +340,250 @@ describe("notification deep-link metadata", () => {
221
340
  const metadata = msg.deepLinkMetadata as Record<string, unknown>;
222
341
  expect(metadata.conversationId).toBe("conv-reused-thread-042");
223
342
  });
343
+
344
+ // ── Reused thread deep-link stability regressions ─────────────────
345
+
346
+ test("reused thread preserves the same conversationId across follow-up notifications", async () => {
347
+ const messages: ServerMessage[] = [];
348
+ const adapter = new VellumAdapter((msg) => messages.push(msg));
349
+
350
+ const stableConversationId = "conv-bound-telegram-dest-001";
351
+
352
+ // First notification to a bound destination
353
+ await adapter.send(
354
+ {
355
+ sourceEventName: "guardian.question",
356
+ copy: { title: "Question 1", body: "Allow file read?" },
357
+ deepLinkTarget: {
358
+ conversationId: stableConversationId,
359
+ messageId: "msg-seed-1",
360
+ },
361
+ },
362
+ { channel: "vellum" },
363
+ );
364
+
365
+ // Follow-up notification reuses the same bound conversation
366
+ await adapter.send(
367
+ {
368
+ sourceEventName: "guardian.question",
369
+ copy: { title: "Question 2", body: "Allow network access?" },
370
+ deepLinkTarget: {
371
+ conversationId: stableConversationId,
372
+ messageId: "msg-seed-2",
373
+ },
374
+ },
375
+ { channel: "vellum" },
376
+ );
377
+
378
+ expect(messages).toHaveLength(2);
379
+
380
+ const meta1 = (messages[0] as unknown as Record<string, unknown>)
381
+ .deepLinkMetadata as Record<string, unknown>;
382
+ const meta2 = (messages[1] as unknown as Record<string, unknown>)
383
+ .deepLinkMetadata as Record<string, unknown>;
384
+
385
+ // Both deep links point to the same conversation
386
+ expect(meta1.conversationId).toBe(stableConversationId);
387
+ expect(meta2.conversationId).toBe(stableConversationId);
388
+
389
+ // But each has a distinct messageId for scroll-to-message targeting
390
+ expect(meta1.messageId).toBe("msg-seed-1");
391
+ expect(meta2.messageId).toBe("msg-seed-2");
392
+ });
393
+
394
+ test("reused thread deep-link messageId changes per delivery for scroll targeting", async () => {
395
+ const messages: ServerMessage[] = [];
396
+ const adapter = new VellumAdapter((msg) => messages.push(msg));
397
+
398
+ const conversationId = "conv-reused-scroll-test";
399
+
400
+ await adapter.send(
401
+ {
402
+ sourceEventName: "reminder.fired",
403
+ copy: { title: "Reminder", body: "First" },
404
+ deepLinkTarget: { conversationId, messageId: "msg-a" },
405
+ },
406
+ { channel: "vellum" },
407
+ );
408
+
409
+ await adapter.send(
410
+ {
411
+ sourceEventName: "reminder.fired",
412
+ copy: { title: "Reminder", body: "Second" },
413
+ deepLinkTarget: { conversationId, messageId: "msg-b" },
414
+ },
415
+ { channel: "vellum" },
416
+ );
417
+
418
+ const meta1 = (messages[0] as unknown as Record<string, unknown>)
419
+ .deepLinkMetadata as Record<string, unknown>;
420
+ const meta2 = (messages[1] as unknown as Record<string, unknown>)
421
+ .deepLinkMetadata as Record<string, unknown>;
422
+
423
+ // Same conversation but different message targets
424
+ expect(meta1.conversationId).toBe(conversationId);
425
+ expect(meta2.conversationId).toBe(conversationId);
426
+ expect(meta1.messageId).not.toBe(meta2.messageId);
427
+ });
428
+
429
+ test("deep-link metadata is stable when conversation is reused via binding-key continuation", async () => {
430
+ const messages: ServerMessage[] = [];
431
+ const adapter = new VellumAdapter((msg) => messages.push(msg));
432
+
433
+ // Simulates the binding-key continuation path: multiple notifications
434
+ // to the same SMS destination reuse the same bound conversation, and
435
+ // the deep-link metadata should reflect the bound conversation ID
436
+ // rather than creating a new one each time.
437
+ const boundConvId = "conv-sms-bound-+15551234567";
438
+
439
+ for (const body of ["Alert 1", "Alert 2", "Alert 3"]) {
440
+ await adapter.send(
441
+ {
442
+ sourceEventName: "activity.complete",
443
+ copy: { title: "Activity", body },
444
+ deepLinkTarget: { conversationId: boundConvId },
445
+ },
446
+ { channel: "vellum" },
447
+ );
448
+ }
449
+
450
+ expect(messages).toHaveLength(3);
451
+
452
+ // All three notifications deep-link to the same bound conversation
453
+ for (const msg of messages) {
454
+ const metadata = (msg as unknown as Record<string, unknown>)
455
+ .deepLinkMetadata as Record<string, unknown>;
456
+ expect(metadata.conversationId).toBe(boundConvId);
457
+ }
458
+ });
459
+ });
460
+
461
+ // ── NotificationBroadcaster deep-link injection ──────────────────────
462
+ //
463
+ // These tests exercise the production code path where the broadcaster
464
+ // calls pairDeliveryWithConversation() and merges the pairing result's
465
+ // conversationId/messageId into deepLinkTarget before passing to the
466
+ // adapter. This catches regressions that the adapter-only tests above
467
+ // would miss (e.g. broadcaster stops merging pairing results).
468
+
469
+ describe("NotificationBroadcaster deep-link injection", () => {
470
+ test("broadcaster merges pairing conversationId into deepLinkTarget for vellum", async () => {
471
+ const vellumAdapter = new MockAdapter("vellum");
472
+ const broadcaster = new NotificationBroadcaster([vellumAdapter]);
473
+
474
+ nextPairingResult = {
475
+ conversationId: "conv-paired-abc",
476
+ messageId: "msg-paired-abc",
477
+ strategy: "start_new_conversation" as const,
478
+ createdNewConversation: true,
479
+ threadDecisionFallbackUsed: false,
480
+ };
481
+
482
+ const signal = makeSignal();
483
+ const decision = makeDecision();
484
+
485
+ await broadcaster.broadcastDecision(signal, decision);
486
+
487
+ expect(vellumAdapter.sent).toHaveLength(1);
488
+ const deepLink = vellumAdapter.sent[0].deepLinkTarget;
489
+ expect(deepLink).toBeDefined();
490
+ expect(deepLink!.conversationId).toBe("conv-paired-abc");
491
+ });
492
+
493
+ test("broadcaster merges pairing messageId into deepLinkTarget for vellum", async () => {
494
+ const vellumAdapter = new MockAdapter("vellum");
495
+ const broadcaster = new NotificationBroadcaster([vellumAdapter]);
496
+
497
+ nextPairingResult = {
498
+ conversationId: "conv-paired-def",
499
+ messageId: "msg-paired-def",
500
+ strategy: "start_new_conversation" as const,
501
+ createdNewConversation: true,
502
+ threadDecisionFallbackUsed: false,
503
+ };
504
+
505
+ const signal = makeSignal();
506
+ const decision = makeDecision();
507
+
508
+ await broadcaster.broadcastDecision(signal, decision);
509
+
510
+ expect(vellumAdapter.sent).toHaveLength(1);
511
+ const deepLink = vellumAdapter.sent[0].deepLinkTarget;
512
+ expect(deepLink).toBeDefined();
513
+ expect(deepLink!.messageId).toBe("msg-paired-def");
514
+ });
515
+
516
+ test("reused conversation deep-link points to the reused conversationId", async () => {
517
+ const vellumAdapter = new MockAdapter("vellum");
518
+ const broadcaster = new NotificationBroadcaster([vellumAdapter]);
519
+
520
+ nextPairingResult = {
521
+ conversationId: "conv-reused-xyz",
522
+ messageId: "msg-reused-xyz",
523
+ strategy: "start_new_conversation" as const,
524
+ createdNewConversation: false,
525
+ threadDecisionFallbackUsed: false,
526
+ };
527
+
528
+ const signal = makeSignal();
529
+ const decision = makeDecision({
530
+ threadActions: {
531
+ vellum: {
532
+ action: "reuse_existing",
533
+ conversationId: "conv-original-placeholder",
534
+ },
535
+ },
536
+ });
537
+
538
+ await broadcaster.broadcastDecision(signal, decision);
539
+
540
+ expect(vellumAdapter.sent).toHaveLength(1);
541
+ const deepLink = vellumAdapter.sent[0].deepLinkTarget;
542
+ expect(deepLink).toBeDefined();
543
+ // The deep-link should use the pairing result, not the original placeholder
544
+ expect(deepLink!.conversationId).toBe("conv-reused-xyz");
545
+ });
546
+
547
+ test("deep-link conversationId is stable across multiple deliveries to the same reused conversation", async () => {
548
+ const vellumAdapter = new MockAdapter("vellum");
549
+ const broadcaster = new NotificationBroadcaster([vellumAdapter]);
550
+
551
+ const stableConvId = "conv-stable-reuse-001";
552
+
553
+ // First delivery
554
+ nextPairingResult = {
555
+ conversationId: stableConvId,
556
+ messageId: "msg-delivery-1",
557
+ strategy: "start_new_conversation" as const,
558
+ createdNewConversation: false,
559
+ threadDecisionFallbackUsed: false,
560
+ };
561
+
562
+ await broadcaster.broadcastDecision(makeSignal(), makeDecision());
563
+
564
+ // Second delivery — same conversation reused via binding-key
565
+ nextPairingResult = {
566
+ conversationId: stableConvId,
567
+ messageId: "msg-delivery-2",
568
+ strategy: "start_new_conversation" as const,
569
+ createdNewConversation: false,
570
+ threadDecisionFallbackUsed: false,
571
+ };
572
+
573
+ await broadcaster.broadcastDecision(makeSignal(), makeDecision());
574
+
575
+ expect(vellumAdapter.sent).toHaveLength(2);
576
+
577
+ const deepLink1 = vellumAdapter.sent[0].deepLinkTarget;
578
+ const deepLink2 = vellumAdapter.sent[1].deepLinkTarget;
579
+
580
+ // Both deliveries point to the same stable conversation
581
+ expect(deepLink1!.conversationId).toBe(stableConvId);
582
+ expect(deepLink2!.conversationId).toBe(stableConvId);
583
+
584
+ // But each has a distinct messageId for scroll targeting
585
+ expect(deepLink1!.messageId).toBe("msg-delivery-1");
586
+ expect(deepLink2!.messageId).toBe("msg-delivery-2");
587
+ });
224
588
  });
225
589
  });
@@ -67,9 +67,9 @@ mock.module("../security/secure-keys.js", () => ({
67
67
  deleteSecureKey: (account: string) => {
68
68
  if (account in secureKeyStore) {
69
69
  delete secureKeyStore[account];
70
- return true;
70
+ return "deleted";
71
71
  }
72
- return false;
72
+ return "not-found";
73
73
  },
74
74
  listSecureKeys: () => Object.keys(secureKeyStore),
75
75
  getBackendType: () => "encrypted",
@@ -10,7 +10,10 @@ const TEST_DIR = join(
10
10
 
11
11
  import { mock } from "bun:test";
12
12
 
13
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
14
+ const realPlatform = require("../util/platform.js");
13
15
  mock.module("../util/platform.js", () => ({
16
+ ...realPlatform,
14
17
  getRootDir: () => TEST_DIR,
15
18
  getDataDir: () => TEST_DIR,
16
19
  getWorkspaceDir: () => TEST_DIR,
@@ -34,19 +37,29 @@ mock.module("../util/platform.js", () => ({
34
37
  isWindows: () => process.platform === "win32",
35
38
  getPlatformName: () => process.platform,
36
39
  getClipboardCommand: () => null,
40
+ readSessionToken: () => null,
37
41
  removeSocketFile: () => {},
38
42
  migratePath: () => {},
39
43
  migrateToWorkspaceLayout: () => {},
40
44
  migrateToDataLayout: () => {},
45
+ readLockfile: () => null,
46
+ writeLockfile: () => {},
41
47
  }));
42
48
 
49
+ const noopLogger = new Proxy({} as Record<string, unknown>, {
50
+ get: (_target, prop) => (prop === "child" ? () => noopLogger : () => {}),
51
+ });
52
+
53
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
54
+ const realLogger = require("../util/logger.js");
43
55
  mock.module("../util/logger.js", () => ({
44
- getLogger: () =>
45
- new Proxy({} as Record<string, unknown>, {
46
- get: () => () => {},
47
- }),
56
+ ...realLogger,
57
+ getLogger: () => noopLogger,
58
+ getCliLogger: () => noopLogger,
48
59
  isDebug: () => false,
49
60
  truncateForLog: (v: string) => v,
61
+ initLogger: () => {},
62
+ pruneOldLogFiles: () => 0,
50
63
  }));
51
64
 
52
65
  const { buildStarterTaskPlaybookSection, buildSystemPrompt } =
@@ -26,7 +26,7 @@ mock.module("../config/loader.js", () => ({
26
26
 
27
27
  provider: "mock-provider",
28
28
  timeouts: { permissionTimeoutSec: 5 },
29
- permissions: { mode: "legacy" },
29
+ permissions: { mode: "workspace" },
30
30
  skills: { load: { extraDirs: [] } },
31
31
  }),
32
32
  }));
@@ -24,7 +24,7 @@ mock.module("../config/loader.js", () => ({
24
24
 
25
25
  daemon: { standaloneRecording: true },
26
26
  provider: "mock-provider",
27
- permissions: { mode: "legacy" },
27
+ permissions: { mode: "workspace" },
28
28
  apiKeys: {},
29
29
  sandbox: { enabled: false },
30
30
  timeouts: { toolExecutionTimeoutSec: 30, permissionTimeoutSec: 5 },
@@ -21,7 +21,10 @@ const noopLogger = {
21
21
  child: () => noopLogger,
22
22
  };
23
23
 
24
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
25
+ const realLogger = require("../util/logger.js");
24
26
  mock.module("../util/logger.js", () => ({
27
+ ...realLogger,
25
28
  getLogger: () => noopLogger,
26
29
  isDebug: () => false,
27
30
  truncateForLog: (v: string) => v,
@@ -34,7 +37,7 @@ mock.module("../config/loader.js", () => ({
34
37
  daemon: { standaloneRecording: true },
35
38
  provider: "mock-provider",
36
39
  model: "mock-model",
37
- permissions: { mode: "legacy" },
40
+ permissions: { mode: "workspace" },
38
41
  apiKeys: {},
39
42
  sandbox: { enabled: false },
40
43
  timeouts: { toolExecutionTimeoutSec: 30, permissionTimeoutSec: 5 },
@@ -342,8 +345,10 @@ mock.module("../providers/provider-send-message.js", () => ({
342
345
  // ── Mock external conversation store ───────────────────────────────────────
343
346
 
344
347
  mock.module("../memory/external-conversation-store.js", () => ({
348
+ getBindingByChannelChat: () => null,
345
349
  getBindingsForConversations: () => new Map(),
346
350
  upsertBinding: () => {},
351
+ upsertOutboundBinding: () => {},
347
352
  }));
348
353
 
349
354
  // ── Mock subagent manager ──────────────────────────────────────────────────
@@ -24,7 +24,7 @@ mock.module("../config/loader.js", () => ({
24
24
 
25
25
  daemon: { standaloneRecording: true },
26
26
  provider: "mock-provider",
27
- permissions: { mode: "legacy" },
27
+ permissions: { mode: "workspace" },
28
28
  apiKeys: {},
29
29
  sandbox: { enabled: false },
30
30
  timeouts: { toolExecutionTimeoutSec: 30, permissionTimeoutSec: 5 },
@@ -32,7 +32,10 @@ const testDir = mkdtempSync(join(tmpdir(), "relay-server-test-"));
32
32
 
33
33
  // ── Platform + logger mocks (must come before any source imports) ────
34
34
 
35
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
36
+ const realPlatform = require("../util/platform.js");
35
37
  mock.module("../util/platform.js", () => ({
38
+ ...realPlatform,
36
39
  getDataDir: () => testDir,
37
40
  isMacOS: () => process.platform === "darwin",
38
41
  isLinux: () => process.platform === "linux",
@@ -44,7 +47,10 @@ mock.module("../util/platform.js", () => ({
44
47
  ensureDataDir: () => {},
45
48
  }));
46
49
 
50
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
51
+ const realLogger = require("../util/logger.js");
47
52
  mock.module("../util/logger.js", () => ({
53
+ ...realLogger,
48
54
  getLogger: () =>
49
55
  new Proxy({} as Record<string, unknown>, {
50
56
  get: () => () => {},
@@ -61,10 +67,12 @@ mock.module("../daemon/identity-helpers.js", () => ({
61
67
  // ── User-reference mock (isolate from real USER.md) ──────────────────
62
68
 
63
69
  let mockUserReference = "my human";
70
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
71
+ const realUserReference = require("../config/user-reference.js");
64
72
  mock.module("../config/user-reference.js", () => ({
73
+ ...realUserReference,
65
74
  resolveUserReference: () => mockUserReference,
66
75
  resolveUserPronouns: () => null,
67
- DEFAULT_USER_REFERENCE: "my human",
68
76
  resolveGuardianName: (guardianDisplayName?: string | null) => {
69
77
  if (mockUserReference !== "my human") {
70
78
  return mockUserReference;