@vellumai/assistant 0.4.37 → 0.4.41

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 (169) hide show
  1. package/ARCHITECTURE.md +3 -3
  2. package/README.md +13 -13
  3. package/bun.lock +80 -24
  4. package/docs/architecture/integrations.md +126 -128
  5. package/docs/runbook-trusted-contacts.md +1 -1
  6. package/docs/trusted-contact-access.md +12 -12
  7. package/package.json +3 -1
  8. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -14
  9. package/src/__tests__/app-bundler.test.ts +209 -0
  10. package/src/__tests__/app-compiler.test.ts +279 -0
  11. package/src/__tests__/app-executors.test.ts +293 -483
  12. package/src/__tests__/app-migration.test.ts +148 -0
  13. package/src/__tests__/app-routes-csp.test.ts +202 -0
  14. package/src/__tests__/avatar-e2e.test.ts +452 -0
  15. package/src/__tests__/avatar-generator.test.ts +193 -0
  16. package/src/__tests__/avatar-router.test.ts +186 -0
  17. package/src/__tests__/browser-download-timeout.test.ts +28 -0
  18. package/src/__tests__/bundled-skill-retrieval-guard.test.ts +9 -9
  19. package/src/__tests__/call-domain.test.ts +3 -7
  20. package/src/__tests__/credential-security-e2e.test.ts +19 -12
  21. package/src/__tests__/credentials-cli.test.ts +30 -4
  22. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +1 -1
  23. package/src/__tests__/handlers-slack-config.test.ts +0 -72
  24. package/src/__tests__/handlers-telegram-config.test.ts +19 -12
  25. package/src/__tests__/handlers-twitter-config.test.ts +105 -48
  26. package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
  27. package/src/__tests__/integration-status.test.ts +15 -5
  28. package/src/__tests__/integrations-cli.test.ts +1 -1
  29. package/src/__tests__/invite-redemption-service.test.ts +62 -7
  30. package/src/__tests__/ipc-snapshot.test.ts +0 -8
  31. package/src/__tests__/managed-avatar-client.test.ts +280 -0
  32. package/src/__tests__/mcp-cli.test.ts +3 -3
  33. package/src/__tests__/oauth-cli.test.ts +203 -0
  34. package/src/__tests__/relay-server.test.ts +3 -3
  35. package/src/__tests__/secret-onetime-send.test.ts +19 -12
  36. package/src/__tests__/secure-keys.test.ts +78 -0
  37. package/src/__tests__/session-messaging-secret-redirect.test.ts +3 -0
  38. package/src/__tests__/slack-channel-config.test.ts +23 -16
  39. package/src/__tests__/slack-share-routes.test.ts +263 -0
  40. package/src/__tests__/sms-messaging-provider.test.ts +3 -1
  41. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +7 -7
  42. package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
  43. package/src/__tests__/trusted-contact-verification.test.ts +10 -10
  44. package/src/__tests__/twilio-config.test.ts +15 -36
  45. package/src/__tests__/twilio-provider.test.ts +4 -0
  46. package/src/__tests__/twitter-auth-handler.test.ts +27 -14
  47. package/src/__tests__/twitter-cli-error-shaping.test.ts +1 -1
  48. package/src/__tests__/twitter-cli-routing.test.ts +38 -53
  49. package/src/__tests__/twitter-oauth-client.test.ts +18 -47
  50. package/src/__tests__/voice-invite-redemption.test.ts +27 -3
  51. package/src/amazon/cart.ts +1 -1
  52. package/src/amazon/client.ts +89 -7
  53. package/src/approvals/guardian-request-resolvers.ts +2 -2
  54. package/src/bundler/app-bundler.ts +77 -32
  55. package/src/bundler/app-compiler.ts +195 -0
  56. package/src/bundler/manifest.ts +1 -1
  57. package/src/bundler/package-resolver.ts +185 -0
  58. package/src/calls/call-domain.ts +4 -14
  59. package/src/calls/relay-server.ts +2 -2
  60. package/src/calls/twilio-config.ts +5 -24
  61. package/src/calls/twilio-rest.ts +19 -5
  62. package/src/cli/amazon.ts +74 -249
  63. package/src/cli/audit.ts +2 -2
  64. package/src/cli/autonomy.ts +9 -9
  65. package/src/cli/channels.ts +5 -5
  66. package/src/cli/completions.ts +27 -27
  67. package/src/cli/config.ts +14 -14
  68. package/src/cli/contacts.ts +27 -27
  69. package/src/cli/credentials.ts +28 -28
  70. package/src/cli/dev.ts +2 -2
  71. package/src/cli/doctor.ts +2 -2
  72. package/src/cli/email.ts +82 -82
  73. package/src/cli/influencer.ts +13 -13
  74. package/src/cli/integrations.ts +19 -144
  75. package/src/cli/keys.ts +10 -10
  76. package/src/cli/map.ts +4 -4
  77. package/src/cli/mcp.ts +17 -17
  78. package/src/cli/memory.ts +18 -18
  79. package/src/cli/notifications.ts +13 -13
  80. package/src/cli/oauth.ts +77 -0
  81. package/src/cli/program.ts +2 -0
  82. package/src/cli/sequence.ts +27 -27
  83. package/src/cli/sessions.ts +12 -12
  84. package/src/cli/trust.ts +8 -8
  85. package/src/cli/twitter.ts +124 -70
  86. package/src/config/bundled-skills/_shared/CLI_RETRIEVAL_PATTERN.md +1 -1
  87. package/src/config/bundled-skills/agentmail/SKILL.md +34 -34
  88. package/src/config/bundled-skills/amazon/SKILL.md +54 -54
  89. package/src/config/bundled-skills/app-builder/SKILL.md +137 -3
  90. package/src/config/bundled-skills/app-builder/tools/app-create.ts +10 -4
  91. package/src/config/bundled-skills/configure-settings/SKILL.md +18 -18
  92. package/src/config/bundled-skills/contacts/SKILL.md +12 -12
  93. package/src/config/bundled-skills/doordash/lib/client.ts +7 -9
  94. package/src/config/bundled-skills/email-setup/SKILL.md +4 -4
  95. package/src/config/bundled-skills/frontend-design/icon.svg +16 -0
  96. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +143 -162
  97. package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +4 -4
  98. package/src/config/bundled-skills/influencer/SKILL.md +13 -13
  99. package/src/config/bundled-skills/mcp-setup/SKILL.md +11 -11
  100. package/src/config/bundled-skills/phone-calls/SKILL.md +48 -54
  101. package/src/config/bundled-skills/public-ingress/SKILL.md +6 -6
  102. package/src/config/bundled-skills/slack-app-setup/SKILL.md +1 -1
  103. package/src/config/bundled-skills/sms-setup/SKILL.md +3 -3
  104. package/src/config/bundled-skills/telegram-setup/SKILL.md +2 -2
  105. package/src/config/bundled-skills/twilio-setup/SKILL.md +136 -225
  106. package/src/config/bundled-skills/twitter/SKILL.md +68 -44
  107. package/src/config/bundled-skills/voice-setup/SKILL.md +2 -2
  108. package/src/config/core-schema.ts +26 -0
  109. package/src/config/env.ts +4 -0
  110. package/src/config/feature-flag-registry.json +9 -1
  111. package/src/config/schema.ts +8 -0
  112. package/src/config/system-prompt.ts +6 -3
  113. package/src/config/templates/BOOTSTRAP.md +7 -5
  114. package/src/contacts/contacts-write.ts +5 -1
  115. package/src/daemon/handlers/apps.ts +31 -4
  116. package/src/daemon/handlers/config-ingress.ts +3 -3
  117. package/src/daemon/handlers/config-integrations.ts +120 -49
  118. package/src/daemon/handlers/config-slack-channel.ts +26 -7
  119. package/src/daemon/handlers/config-slack.ts +1 -54
  120. package/src/daemon/handlers/config-telegram.ts +28 -10
  121. package/src/daemon/handlers/config.ts +1 -4
  122. package/src/daemon/handlers/twitter-auth.ts +11 -4
  123. package/src/daemon/ipc-contract/apps.ts +0 -13
  124. package/src/daemon/ipc-contract-inventory.json +0 -2
  125. package/src/daemon/lifecycle.ts +8 -1
  126. package/src/daemon/session-messaging.ts +2 -2
  127. package/src/daemon/tool-side-effects.ts +30 -0
  128. package/src/email/providers/agentmail.ts +1 -1
  129. package/src/email/providers/index.ts +1 -1
  130. package/src/email/service.ts +1 -1
  131. package/src/gallery/default-gallery.ts +538 -0
  132. package/src/gallery/gallery-manifest.ts +5 -1
  133. package/src/influencer/client.ts +8 -6
  134. package/src/mcp/client.ts +1 -1
  135. package/src/media/avatar-router.ts +99 -0
  136. package/src/media/avatar-types.ts +60 -0
  137. package/src/media/managed-avatar-client.ts +189 -0
  138. package/src/memory/app-migration.ts +114 -0
  139. package/src/memory/app-store.ts +11 -0
  140. package/src/memory/qdrant-client.ts +1 -1
  141. package/src/messaging/providers/slack/client.ts +12 -2
  142. package/src/messaging/providers/sms/adapter.ts +6 -10
  143. package/src/migrations/data-layout.ts +8 -1
  144. package/src/oauth/token-persistence.ts +9 -6
  145. package/src/runtime/assistant-scope.ts +5 -0
  146. package/src/runtime/auth/route-policy.ts +4 -0
  147. package/src/runtime/channel-readiness-service.ts +9 -4
  148. package/src/runtime/gateway-internal-client.ts +11 -3
  149. package/src/runtime/http-server.ts +2 -0
  150. package/src/runtime/invite-redemption-service.ts +23 -13
  151. package/src/runtime/middleware/twilio-validation.ts +2 -2
  152. package/src/runtime/routes/app-routes.ts +131 -3
  153. package/src/runtime/routes/inbound-stages/verification-intercept.ts +3 -3
  154. package/src/runtime/routes/integration-routes.ts +2 -2
  155. package/src/runtime/routes/slack-share-routes.ts +235 -0
  156. package/src/runtime/routes/twilio-routes.ts +47 -34
  157. package/src/schedule/integration-status.ts +2 -3
  158. package/src/security/token-manager.ts +11 -3
  159. package/src/tools/apps/executors.ts +116 -8
  160. package/src/tools/browser/browser-manager.ts +30 -2
  161. package/src/tools/browser/chrome-cdp.ts +31 -3
  162. package/src/tools/credentials/vault.ts +9 -7
  163. package/src/tools/executor.ts +4 -0
  164. package/src/tools/system/avatar-generator.ts +55 -34
  165. package/src/twitter/client.ts +1 -1
  166. package/src/twitter/oauth-client.ts +31 -43
  167. package/src/twitter/router.ts +25 -23
  168. package/src/util/platform.ts +5 -0
  169. package/src/slack/slack-webhook.ts +0 -66
@@ -10,17 +10,30 @@ const testDir = mkdtempSync(join(tmpdir(), "handlers-twitter-auth-test-"));
10
10
  let rawConfigStore: Record<string, unknown> = {};
11
11
  let mockIngressPublicBaseUrl: string | undefined = "https://test.example.com";
12
12
 
13
+ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
14
+ const keys = path.split(".");
15
+ let current: unknown = obj;
16
+ for (const key of keys) {
17
+ if (current == null || typeof current !== "object") {
18
+ return undefined;
19
+ }
20
+ current = (current as Record<string, unknown>)[key];
21
+ }
22
+ return current;
23
+ }
24
+
13
25
  mock.module("../config/loader.js", () => ({
14
26
  getConfig: () => ({
15
27
  ui: {},
16
28
  }),
17
29
  loadConfig: () => ({ ingress: { publicBaseUrl: mockIngressPublicBaseUrl } }),
18
- loadRawConfig: () => ({ ...rawConfigStore }),
30
+ loadRawConfig: () => structuredClone(rawConfigStore),
19
31
  saveRawConfig: (cfg: Record<string, unknown>) => {
20
- rawConfigStore = { ...cfg };
32
+ rawConfigStore = structuredClone(cfg);
21
33
  },
22
34
  saveConfig: () => {},
23
35
  invalidateConfigCache: () => {},
36
+ getNestedValue,
24
37
  }));
25
38
 
26
39
  mock.module("../inbound/public-ingress-urls.js", () => ({
@@ -207,7 +220,7 @@ describe("Twitter auth handler", () => {
207
220
 
208
221
  describe("twitter_auth_start", () => {
209
222
  test("fails if mode is not local_byo", async () => {
210
- rawConfigStore = { twitterIntegrationMode: "managed" };
223
+ rawConfigStore = { twitter: { integrationMode: "managed" } };
211
224
 
212
225
  const msg: TwitterAuthStartRequest = { type: "twitter_auth_start" };
213
226
  const { ctx, sent } = createTestContext();
@@ -228,7 +241,7 @@ describe("Twitter auth handler", () => {
228
241
  });
229
242
 
230
243
  test("fails if no client credentials configured", async () => {
231
- rawConfigStore = { twitterIntegrationMode: "local_byo" };
244
+ rawConfigStore = { twitter: { integrationMode: "local_byo" } };
232
245
  // No client ID in secure storage
233
246
 
234
247
  const msg: TwitterAuthStartRequest = { type: "twitter_auth_start" };
@@ -249,7 +262,7 @@ describe("Twitter auth handler", () => {
249
262
  });
250
263
 
251
264
  test("succeeds with valid config (mock orchestrator)", async () => {
252
- rawConfigStore = { twitterIntegrationMode: "local_byo" };
265
+ rawConfigStore = { twitter: { integrationMode: "local_byo" } };
253
266
  secureKeyStore["credential:integration:twitter:oauth_client_id"] =
254
267
  "test-client-id";
255
268
  secureKeyStore["credential:integration:twitter:oauth_client_secret"] =
@@ -283,7 +296,7 @@ describe("Twitter auth handler", () => {
283
296
  });
284
297
 
285
298
  test("delegates to orchestrateOAuthConnect with correct options", async () => {
286
- rawConfigStore = { twitterIntegrationMode: "local_byo" };
299
+ rawConfigStore = { twitter: { integrationMode: "local_byo" } };
287
300
  secureKeyStore["credential:integration:twitter:oauth_client_id"] =
288
301
  "test-client-id";
289
302
  secureKeyStore["credential:integration:twitter:oauth_client_secret"] =
@@ -312,7 +325,7 @@ describe("Twitter auth handler", () => {
312
325
  });
313
326
 
314
327
  test("fails fast with actionable error when no ingress URL is configured", async () => {
315
- rawConfigStore = { twitterIntegrationMode: "local_byo" };
328
+ rawConfigStore = { twitter: { integrationMode: "local_byo" } };
316
329
  secureKeyStore["credential:integration:twitter:oauth_client_id"] =
317
330
  "test-client-id";
318
331
  mockIngressPublicBaseUrl = undefined;
@@ -349,7 +362,7 @@ describe("Twitter auth handler", () => {
349
362
  });
350
363
 
351
364
  test("maps orchestrator error result to twitter_auth_result", async () => {
352
- rawConfigStore = { twitterIntegrationMode: "local_byo" };
365
+ rawConfigStore = { twitter: { integrationMode: "local_byo" } };
353
366
  secureKeyStore["credential:integration:twitter:oauth_client_id"] =
354
367
  "test-client-id";
355
368
 
@@ -379,7 +392,7 @@ describe("Twitter auth handler", () => {
379
392
 
380
393
  describe("auth hardening", () => {
381
394
  test("OAuth cancel path returns sanitized failure", async () => {
382
- rawConfigStore = { twitterIntegrationMode: "local_byo" };
395
+ rawConfigStore = { twitter: { integrationMode: "local_byo" } };
383
396
  secureKeyStore["credential:integration:twitter:oauth_client_id"] =
384
397
  "test-client-id";
385
398
 
@@ -404,7 +417,7 @@ describe("Twitter auth handler", () => {
404
417
  });
405
418
 
406
419
  test("OAuth timeout path returns sanitized failure", async () => {
407
- rawConfigStore = { twitterIntegrationMode: "local_byo" };
420
+ rawConfigStore = { twitter: { integrationMode: "local_byo" } };
408
421
  secureKeyStore["credential:integration:twitter:oauth_client_id"] =
409
422
  "test-client-id";
410
423
 
@@ -431,7 +444,7 @@ describe("Twitter auth handler", () => {
431
444
  });
432
445
 
433
446
  test("error payload never includes secrets or raw provider bodies", async () => {
434
- rawConfigStore = { twitterIntegrationMode: "local_byo" };
447
+ rawConfigStore = { twitter: { integrationMode: "local_byo" } };
435
448
  secureKeyStore["credential:integration:twitter:oauth_client_id"] =
436
449
  "test-client-id";
437
450
 
@@ -466,7 +479,7 @@ describe("Twitter auth handler", () => {
466
479
  });
467
480
 
468
481
  test("succeeds even when identity verification returns no accountInfo", async () => {
469
- rawConfigStore = { twitterIntegrationMode: "local_byo" };
482
+ rawConfigStore = { twitter: { integrationMode: "local_byo" } };
470
483
  secureKeyStore["credential:integration:twitter:oauth_client_id"] =
471
484
  "test-client-id";
472
485
 
@@ -499,7 +512,7 @@ describe("Twitter auth handler", () => {
499
512
 
500
513
  describe("twitter_auth_status", () => {
501
514
  test("returns disconnected when no token exists", () => {
502
- rawConfigStore = { twitterIntegrationMode: "local_byo" };
515
+ rawConfigStore = { twitter: { integrationMode: "local_byo" } };
503
516
 
504
517
  const msg: TwitterAuthStatusRequest = { type: "twitter_auth_status" };
505
518
  const { ctx, sent } = createTestContext();
@@ -519,7 +532,7 @@ describe("Twitter auth handler", () => {
519
532
  });
520
533
 
521
534
  test("returns connected with account info when token exists", () => {
522
- rawConfigStore = { twitterIntegrationMode: "local_byo" };
535
+ rawConfigStore = { twitter: { integrationMode: "local_byo" } };
523
536
  secureKeyStore["credential:integration:twitter:access_token"] =
524
537
  "test-access-token";
525
538
  credentialMetadataStore.push({
@@ -19,7 +19,7 @@ import { SessionExpiredError } from "../twitter/client.js";
19
19
 
20
20
  const SESSION_EXPIRED_MSG =
21
21
  "Your Twitter session has expired. Please sign in to Twitter in Chrome — " +
22
- "run `vellum twitter refresh` to capture your session automatically.";
22
+ "run `assistant twitter refresh` to capture your session automatically.";
23
23
 
24
24
  /**
25
25
  * Replicates the error-to-payload logic from `run()` in twitter.ts.
@@ -2,8 +2,6 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  // --- Mocks (must be declared before importing the module under test) ---
4
4
 
5
- let mockStrategy: string | undefined = undefined;
6
- let mockOauthAvailable = false;
7
5
  let mockOauthPostResult: {
8
6
  tweetId: string;
9
7
  text: string;
@@ -17,33 +15,13 @@ let mockBrowserPostResult: {
17
15
  } | null = null;
18
16
  let mockBrowserPostError: Error | null = null;
19
17
 
20
- // Mock the config loader to return a controllable strategy
21
- mock.module("../config/loader.js", () => ({
22
- loadRawConfig: () => {
23
- if (mockStrategy !== undefined) {
24
- return { twitterOperationStrategy: mockStrategy };
25
- }
26
- return {};
27
- },
28
- loadConfig: () => ({}),
29
- saveConfig: () => {},
30
- saveRawConfig: () => {},
31
- getConfig: () => ({
32
- ui: {},
33
- }),
34
- invalidateConfigCache: () => {},
35
- getNestedValue: () => undefined,
36
- setNestedValue: () => {},
37
- API_KEY_PROVIDERS: [],
38
- }));
39
-
40
18
  // Mock the OAuth client
41
19
  mock.module("../twitter/oauth-client.js", () => ({
42
- oauthIsAvailable: () => mockOauthAvailable,
20
+ oauthIsAvailable: (token?: string) => token != null && token.length > 0,
43
21
  oauthSupportsOperation: (op: string) => op === "post" || op === "reply",
44
22
  oauthPostTweet: async (
45
23
  _text: string,
46
- _opts?: { inReplyToTweetId?: string },
24
+ _opts: { inReplyToTweetId?: string; oauthToken: string },
47
25
  ) => {
48
26
  if (mockOauthPostError) throw mockOauthPostError;
49
27
  if (mockOauthPostResult) return mockOauthPostResult;
@@ -100,8 +78,6 @@ mock.module("../util/logger.js", () => ({
100
78
  import { routedPostTweet } from "../twitter/router.js";
101
79
 
102
80
  beforeEach(() => {
103
- mockStrategy = undefined;
104
- mockOauthAvailable = false;
105
81
  mockOauthPostResult = null;
106
82
  mockOauthPostError = null;
107
83
  mockBrowserPostResult = null;
@@ -111,14 +87,16 @@ beforeEach(() => {
111
87
  describe("Twitter strategy router", () => {
112
88
  describe("auto strategy", () => {
113
89
  test("uses OAuth when available and supported", async () => {
114
- mockOauthAvailable = true;
115
90
  mockOauthPostResult = {
116
91
  tweetId: "111",
117
92
  text: "hello",
118
93
  url: "https://x.com/u/status/111",
119
94
  };
120
95
 
121
- const { result, pathUsed } = await routedPostTweet("hello");
96
+ const { result, pathUsed } = await routedPostTweet("hello", {
97
+ strategy: "auto",
98
+ oauthToken: "test-token",
99
+ });
122
100
 
123
101
  expect(pathUsed).toBe("oauth");
124
102
  expect(result.tweetId).toBe("111");
@@ -127,21 +105,21 @@ describe("Twitter strategy router", () => {
127
105
  });
128
106
 
129
107
  test("falls back to browser when OAuth is unavailable", async () => {
130
- mockOauthAvailable = false;
131
108
  mockBrowserPostResult = {
132
109
  tweetId: "222",
133
110
  text: "hello",
134
111
  url: "https://x.com/u/status/222",
135
112
  };
136
113
 
137
- const { result, pathUsed } = await routedPostTweet("hello");
114
+ const { result, pathUsed } = await routedPostTweet("hello", {
115
+ strategy: "auto",
116
+ });
138
117
 
139
118
  expect(pathUsed).toBe("browser");
140
119
  expect(result.tweetId).toBe("222");
141
120
  });
142
121
 
143
122
  test("falls back to browser when OAuth fails", async () => {
144
- mockOauthAvailable = true;
145
123
  mockOauthPostError = new Error("OAuth token expired");
146
124
  mockBrowserPostResult = {
147
125
  tweetId: "333",
@@ -149,31 +127,38 @@ describe("Twitter strategy router", () => {
149
127
  url: "https://x.com/u/status/333",
150
128
  };
151
129
 
152
- const { result, pathUsed } = await routedPostTweet("hello");
130
+ const { result, pathUsed } = await routedPostTweet("hello", {
131
+ strategy: "auto",
132
+ oauthToken: "test-token",
133
+ });
153
134
 
154
135
  expect(pathUsed).toBe("browser");
155
136
  expect(result.tweetId).toBe("333");
156
137
  });
157
138
 
158
139
  test("constructs URL from tweetId when OAuth result has no url", async () => {
159
- mockOauthAvailable = true;
160
140
  mockOauthPostResult = { tweetId: "444", text: "no url" };
161
141
 
162
- const { result, pathUsed } = await routedPostTweet("no url");
142
+ const { result, pathUsed } = await routedPostTweet("no url", {
143
+ strategy: "auto",
144
+ oauthToken: "test-token",
145
+ });
163
146
 
164
147
  expect(pathUsed).toBe("oauth");
165
148
  expect(result.url).toBe("https://x.com/i/status/444");
166
149
  });
167
150
 
168
151
  test("throws combined error when both OAuth and browser fail with SessionExpiredError", async () => {
169
- mockOauthAvailable = true;
170
152
  mockOauthPostError = new Error("OAuth failed");
171
153
  mockBrowserPostError = new MockSessionExpiredError(
172
154
  "Browser session expired",
173
155
  );
174
156
 
175
157
  try {
176
- await routedPostTweet("will fail");
158
+ await routedPostTweet("will fail", {
159
+ strategy: "auto",
160
+ oauthToken: "test-token",
161
+ });
177
162
  expect(true).toBe(false); // should not reach
178
163
  } catch (err) {
179
164
  const e = err as Error & { pathUsed: string; oauthError?: string };
@@ -187,11 +172,8 @@ describe("Twitter strategy router", () => {
187
172
 
188
173
  describe("explicit oauth strategy", () => {
189
174
  test("fails with helpful error when OAuth is not configured", async () => {
190
- mockStrategy = "oauth";
191
- mockOauthAvailable = false;
192
-
193
175
  try {
194
- await routedPostTweet("hello");
176
+ await routedPostTweet("hello", { strategy: "oauth" });
195
177
  expect(true).toBe(false); // should not reach
196
178
  } catch (err) {
197
179
  const e = err as Error & {
@@ -199,18 +181,21 @@ describe("Twitter strategy router", () => {
199
181
  suggestAlternative: string;
200
182
  };
201
183
  expect(e.message).toContain("OAuth is not configured");
202
- expect(e.message).toContain("vellum x strategy set browser");
184
+ expect(e.message).toContain(
185
+ "assistant config set twitter.operationStrategy browser",
186
+ );
203
187
  expect(e.pathUsed).toBe("oauth");
204
188
  expect(e.suggestAlternative).toBe("browser");
205
189
  }
206
190
  });
207
191
 
208
192
  test("uses OAuth when available", async () => {
209
- mockStrategy = "oauth";
210
- mockOauthAvailable = true;
211
193
  mockOauthPostResult = { tweetId: "555", text: "oauth post" };
212
194
 
213
- const { result, pathUsed } = await routedPostTweet("oauth post");
195
+ const { result, pathUsed } = await routedPostTweet("oauth post", {
196
+ strategy: "oauth",
197
+ oauthToken: "test-token",
198
+ });
214
199
 
215
200
  expect(pathUsed).toBe("oauth");
216
201
  expect(result.tweetId).toBe("555");
@@ -219,26 +204,26 @@ describe("Twitter strategy router", () => {
219
204
 
220
205
  describe("explicit browser strategy", () => {
221
206
  test("uses browser directly, ignoring OAuth availability", async () => {
222
- mockStrategy = "browser";
223
- mockOauthAvailable = true; // available but should be ignored
224
207
  mockBrowserPostResult = {
225
208
  tweetId: "666",
226
209
  text: "browser post",
227
210
  url: "https://x.com/u/status/666",
228
211
  };
229
212
 
230
- const { result, pathUsed } = await routedPostTweet("browser post");
213
+ const { result, pathUsed } = await routedPostTweet("browser post", {
214
+ strategy: "browser",
215
+ oauthToken: "test-token", // available but should be ignored
216
+ });
231
217
 
232
218
  expect(pathUsed).toBe("browser");
233
219
  expect(result.tweetId).toBe("666");
234
220
  });
235
221
 
236
222
  test("preserves SessionExpiredError type with router metadata", async () => {
237
- mockStrategy = "browser";
238
223
  mockBrowserPostError = new MockSessionExpiredError("Session expired");
239
224
 
240
225
  try {
241
- await routedPostTweet("will fail");
226
+ await routedPostTweet("will fail", { strategy: "browser" });
242
227
  expect(true).toBe(false); // should not reach
243
228
  } catch (err) {
244
229
  const e = err as Error & {
@@ -253,11 +238,10 @@ describe("Twitter strategy router", () => {
253
238
  });
254
239
 
255
240
  test("re-throws non-session errors without wrapping", async () => {
256
- mockStrategy = "browser";
257
241
  mockBrowserPostError = new Error("Network failure");
258
242
 
259
243
  try {
260
- await routedPostTweet("will fail");
244
+ await routedPostTweet("will fail", { strategy: "browser" });
261
245
  expect(true).toBe(false); // should not reach
262
246
  } catch (err) {
263
247
  expect((err as Error).message).toBe("Network failure");
@@ -267,7 +251,6 @@ describe("Twitter strategy router", () => {
267
251
 
268
252
  describe("reply routing", () => {
269
253
  test("auto strategy routes reply through OAuth when available", async () => {
270
- mockOauthAvailable = true;
271
254
  mockOauthPostResult = {
272
255
  tweetId: "777",
273
256
  text: "reply text",
@@ -276,6 +259,8 @@ describe("Twitter strategy router", () => {
276
259
 
277
260
  const { result, pathUsed } = await routedPostTweet("reply text", {
278
261
  inReplyToTweetId: "100",
262
+ strategy: "auto",
263
+ oauthToken: "test-token",
279
264
  });
280
265
 
281
266
  expect(pathUsed).toBe("oauth");
@@ -283,7 +268,6 @@ describe("Twitter strategy router", () => {
283
268
  });
284
269
 
285
270
  test("browser strategy routes reply through browser", async () => {
286
- mockStrategy = "browser";
287
271
  mockBrowserPostResult = {
288
272
  tweetId: "888",
289
273
  text: "reply text",
@@ -292,6 +276,7 @@ describe("Twitter strategy router", () => {
292
276
 
293
277
  const { result, pathUsed } = await routedPostTweet("reply text", {
294
278
  inReplyToTweetId: "200",
279
+ strategy: "browser",
295
280
  });
296
281
 
297
282
  expect(pathUsed).toBe("browser");
@@ -2,39 +2,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  // --- Mocks (must be declared before importing the module under test) ---
4
4
 
5
- let secureKeyStore: Record<string, string> = {};
6
-
7
- mock.module("../security/secure-keys.js", () => ({
8
- getSecureKey: (account: string) => secureKeyStore[account] ?? undefined,
9
- setSecureKey: (account: string, value: string) => {
10
- secureKeyStore[account] = value;
11
- return true;
12
- },
13
- deleteSecureKey: () => "deleted",
14
- listSecureKeys: () => Object.keys(secureKeyStore),
15
- getBackendType: () => "encrypted",
16
- isDowngradedFromKeychain: () => false,
17
- _resetBackend: () => {},
18
- _setBackend: () => {},
19
- }));
20
-
21
- // withValidToken: call the callback directly with a fake token.
22
- mock.module("../security/token-manager.js", () => ({
23
- withValidToken: async (
24
- _service: string,
25
- cb: (token: string) => Promise<unknown>,
26
- ) => cb("fake-oauth-token"),
27
- TokenExpiredError: class TokenExpiredError extends Error {
28
- constructor(
29
- public readonly service: string,
30
- message?: string,
31
- ) {
32
- super(message ?? `Token expired for "${service}".`);
33
- this.name = "TokenExpiredError";
34
- }
35
- },
36
- }));
37
-
38
5
  mock.module("../util/logger.js", () => ({
39
6
  getLogger: () => ({
40
7
  info: () => {},
@@ -65,7 +32,6 @@ const originalFetch = globalThis.fetch;
65
32
  let _fetchMock: ReturnType<typeof mock> | null = null;
66
33
 
67
34
  beforeEach(() => {
68
- secureKeyStore = {};
69
35
  _fetchMock = null;
70
36
  });
71
37
 
@@ -101,7 +67,9 @@ describe("Twitter OAuth client", () => {
101
67
  json: { data: { id: "12345", text: "Hello world" } },
102
68
  });
103
69
 
104
- const result = await oauthPostTweet("Hello world");
70
+ const result = await oauthPostTweet("Hello world", {
71
+ oauthToken: "fake-oauth-token",
72
+ });
105
73
 
106
74
  expect(result.tweetId).toBe("12345");
107
75
  expect(result.text).toBe("Hello world");
@@ -132,6 +100,7 @@ describe("Twitter OAuth client", () => {
132
100
 
133
101
  const result = await oauthPostTweet("My reply", {
134
102
  inReplyToTweetId: "11111",
103
+ oauthToken: "fake-oauth-token",
135
104
  });
136
105
 
137
106
  expect(result.tweetId).toBe("67890");
@@ -150,12 +119,12 @@ describe("Twitter OAuth client", () => {
150
119
  text: "Rate limit exceeded",
151
120
  });
152
121
 
153
- await expect(oauthPostTweet("will fail")).rejects.toThrow(
154
- /Twitter API error \(429\)/,
155
- );
122
+ await expect(
123
+ oauthPostTweet("will fail", { oauthToken: "fake-oauth-token" }),
124
+ ).rejects.toThrow(/Twitter API error \(429\)/);
156
125
  });
157
126
 
158
- test("attaches status to thrown error for token manager retry", async () => {
127
+ test("throws with status in error message on 401", async () => {
159
128
  mockFetch({
160
129
  ok: false,
161
130
  status: 401,
@@ -163,23 +132,25 @@ describe("Twitter OAuth client", () => {
163
132
  });
164
133
 
165
134
  try {
166
- await oauthPostTweet("will fail");
135
+ await oauthPostTweet("will fail", { oauthToken: "fake-oauth-token" });
167
136
  expect(true).toBe(false); // should not reach
168
137
  } catch (err) {
169
- expect((err as Error & { status: number }).status).toBe(401);
138
+ expect((err as Error).message).toContain("401");
170
139
  }
171
140
  });
172
141
  });
173
142
 
174
143
  describe("oauthIsAvailable", () => {
175
- test("returns true when access token exists", () => {
176
- secureKeyStore["credential:integration:twitter:access_token"] =
177
- "some-token";
178
- expect(oauthIsAvailable()).toBe(true);
144
+ test("returns true when token is provided", () => {
145
+ expect(oauthIsAvailable("some-token")).toBe(true);
146
+ });
147
+
148
+ test("returns false when token is undefined", () => {
149
+ expect(oauthIsAvailable(undefined)).toBe(false);
179
150
  });
180
151
 
181
- test("returns false when no access token", () => {
182
- expect(oauthIsAvailable()).toBe(false);
152
+ test("returns false when token is empty string", () => {
153
+ expect(oauthIsAvailable("")).toBe(false);
183
154
  });
184
155
  });
185
156
 
@@ -24,7 +24,8 @@ mock.module("../util/logger.js", () => ({
24
24
  }),
25
25
  }));
26
26
 
27
- import { upsertMember } from "../contacts/contacts-write.js";
27
+ import { findContactChannel } from "../contacts/contact-store.js";
28
+ import { upsertContactChannel } from "../contacts/contacts-write.js";
28
29
  import { getSqlite, initializeDb, resetDb } from "../memory/db.js";
29
30
  import { createInvite, revokeInvite } from "../memory/invite-store.js";
30
31
  import { redeemVoiceInviteCode } from "../runtime/invite-redemption-service.js";
@@ -177,6 +178,29 @@ describe("redeemVoiceInviteCode", () => {
177
178
  });
178
179
  });
179
180
 
181
+ test("marks channel as verified via invite on voice redemption", () => {
182
+ const phone = "+15551234567";
183
+ const { code } = createVoiceInvite({ callerPhone: phone });
184
+
185
+ const result = redeemVoiceInviteCode({
186
+ callerExternalUserId: phone,
187
+ sourceChannel: "voice",
188
+ code,
189
+ });
190
+
191
+ expect(result.ok).toBe(true);
192
+
193
+ const channelResult = findContactChannel({
194
+ channelType: "voice",
195
+ externalUserId: phone,
196
+ });
197
+
198
+ expect(channelResult).not.toBeNull();
199
+ expect(channelResult!.channel.verifiedAt).toBeGreaterThan(0);
200
+ expect(channelResult!.channel.verifiedVia).toBe("invite");
201
+ expect(channelResult!.channel.status).toBe("active");
202
+ });
203
+
180
204
  test("wrong caller identity fails with generic error", () => {
181
205
  const { code } = createVoiceInvite({ callerPhone: "+15551234567" });
182
206
 
@@ -281,7 +305,7 @@ describe("redeemVoiceInviteCode", () => {
281
305
  const { code } = createVoiceInvite({ callerPhone: phone });
282
306
 
283
307
  // Pre-create an active member for this phone on voice channel
284
- upsertMember({
308
+ upsertContactChannel({
285
309
  sourceChannel: "voice",
286
310
  externalUserId: phone,
287
311
  status: "active",
@@ -306,7 +330,7 @@ describe("redeemVoiceInviteCode", () => {
306
330
  const phone = "+15551234567";
307
331
  const { code } = createVoiceInvite({ callerPhone: phone });
308
332
 
309
- upsertMember({
333
+ upsertContactChannel({
310
334
  sourceChannel: "voice",
311
335
  externalUserId: phone,
312
336
  status: "blocked",
@@ -321,7 +321,7 @@ export async function addToCart(opts: {
321
321
  __error: true,
322
322
  __message: 'Add-to-cart failed: ASIN ' + ${JSON.stringify(
323
323
  opts.asin,
324
- )} + ' was not found in the Fresh cart after adding. The item may be unavailable or the session cookies may be stale. Try running vellum amazon refresh.'
324
+ )} + ' was not found in the Fresh cart after adding. The item may be unavailable or the session cookies may be stale. Try running assistant amazon refresh.'
325
325
  });
326
326
  }
327
327
  items = allFreshItems.map(function(item) {