@vellumai/assistant 0.9.0 → 0.9.1-staging.1

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 (222) hide show
  1. package/ARCHITECTURE.md +18 -34
  2. package/bun.lock +7 -8
  3. package/docs/activation-funnel-telemetry.md +4 -4
  4. package/docs/architecture/security.md +29 -28
  5. package/docs/stt-provider-onboarding.md +3 -5
  6. package/docs/workflows-testing.md +13 -44
  7. package/docs/workflows.md +3 -5
  8. package/node_modules/@vellumai/ces-client/src/__tests__/ces-client.test.ts +47 -0
  9. package/node_modules/@vellumai/ces-client/src/rpc-client.ts +28 -5
  10. package/node_modules/@vellumai/environments/src/seeds.ts +2 -5
  11. package/node_modules/@vellumai/gateway-client/src/index.ts +17 -6
  12. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +119 -0
  13. package/node_modules/@vellumai/gateway-client/src/types.ts +15 -84
  14. package/openapi.yaml +135 -59
  15. package/package.json +2 -1
  16. package/scripts/sync-llm-catalog.ts +6 -15
  17. package/scripts/sync-web-search-catalog.ts +3 -11
  18. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +14 -26
  19. package/src/__tests__/agent-loop-compaction-strip.test.ts +240 -0
  20. package/src/__tests__/agent-loop-output-hooks.test.ts +69 -0
  21. package/src/__tests__/agent-loop-override-profile.test.ts +25 -0
  22. package/src/__tests__/always-loaded-tools-guard.test.ts +2 -3
  23. package/src/__tests__/app-dir-path-guard.test.ts +0 -1
  24. package/src/__tests__/assistant-feature-flag-guard.test.ts +1 -4
  25. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +0 -2
  26. package/src/__tests__/avatar-identity-sync.test.ts +2 -27
  27. package/src/__tests__/btw-routes.test.ts +6 -8
  28. package/src/__tests__/checker.test.ts +0 -3
  29. package/src/__tests__/config-loader-backfill.test.ts +103 -6
  30. package/src/__tests__/config-watcher.test.ts +0 -18
  31. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +22 -0
  32. package/src/__tests__/credential-broker.test.ts +449 -1
  33. package/src/__tests__/credential-execution-tools.test.ts +0 -1
  34. package/src/__tests__/credential-prompt-route.test.ts +3 -4
  35. package/src/__tests__/credential-routes.test.ts +360 -0
  36. package/src/__tests__/credential-security-invariants.test.ts +2 -13
  37. package/src/__tests__/dynamic-page-surface.test.ts +101 -1
  38. package/src/__tests__/fixtures/credential-security-fixtures.ts +2 -33
  39. package/src/__tests__/gateway-only-guard.test.ts +3 -7
  40. package/src/__tests__/identity-routes.test.ts +0 -189
  41. package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
  42. package/src/__tests__/invite-redemption-service.test.ts +4 -4
  43. package/src/__tests__/llm-callsite-catalog.test.ts +5 -6
  44. package/src/__tests__/llm-catalog-parity.test.ts +0 -22
  45. package/src/__tests__/llm-resolver.test.ts +49 -24
  46. package/src/__tests__/oauth-provider-seed-logos.test.ts +4 -6
  47. package/src/__tests__/onboarding-persona-write.test.ts +1 -1
  48. package/src/__tests__/persona-resolver.test.ts +11 -14
  49. package/src/__tests__/plugin-api-model-profiles.test.ts +178 -0
  50. package/src/__tests__/registry.test.ts +2 -7
  51. package/src/__tests__/schedule-routes-workflow-validation.test.ts +1 -10
  52. package/src/__tests__/schedule-routes.test.ts +0 -30
  53. package/src/__tests__/schedule-tools.test.ts +2 -18
  54. package/src/__tests__/skill-execute-input.test.ts +46 -1
  55. package/src/__tests__/skill-runtime-path.test.ts +2 -3
  56. package/src/__tests__/subagent-tools.test.ts +116 -0
  57. package/src/__tests__/surface-completion-nudge-hook.test.ts +367 -0
  58. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1 -29
  59. package/src/__tests__/token-manager.test.ts +519 -0
  60. package/src/__tests__/tool-executor.test.ts +0 -79
  61. package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
  62. package/src/__tests__/trusted-contact-verification.test.ts +6 -6
  63. package/src/__tests__/voice-invite-redemption.test.ts +2 -2
  64. package/src/__tests__/web-search-catalog-parity.test.ts +6 -25
  65. package/src/__tests__/workspace-greetings.test.ts +152 -0
  66. package/src/agent/loop.ts +25 -5
  67. package/src/api/README.md +6 -6
  68. package/src/api/responses/conversation-message.ts +2 -4
  69. package/src/api/responses/home.ts +0 -4
  70. package/src/approvals/guardian-request-resolvers.ts +2 -2
  71. package/src/calls/relay-access-wait.ts +1 -1
  72. package/src/calls/voice-session-bridge.ts +2 -2
  73. package/src/cli/commands/plugins.ts +143 -2
  74. package/src/cli/lib/__tests__/diff-plugin.test.ts +443 -0
  75. package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +313 -0
  76. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +253 -2
  77. package/src/cli/lib/diff-plugin.ts +346 -0
  78. package/src/cli/lib/install-from-github.ts +105 -17
  79. package/src/cli/lib/merge-plugin-tree.ts +228 -0
  80. package/src/cli/lib/plugin-fingerprint.ts +14 -0
  81. package/src/cli/lib/upgrade-plugin.ts +270 -10
  82. package/src/cli/program.ts +0 -2
  83. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  84. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  85. package/src/config/bundled-skills/workflows/SKILL.md +0 -1
  86. package/src/config/bundled-tool-registry.ts +2 -7
  87. package/src/config/call-site-defaults.ts +12 -2
  88. package/src/config/feature-flag-registry.json +1 -17
  89. package/src/config/inference-profile-validation.ts +26 -0
  90. package/src/config/loader.ts +4 -0
  91. package/src/config/profile-order.ts +28 -0
  92. package/src/config/schemas/elevenlabs.ts +0 -1
  93. package/src/config/schemas/platform.ts +0 -8
  94. package/src/config/seed-inference-profiles.ts +25 -20
  95. package/src/contacts/contact-store.ts +87 -96
  96. package/src/contacts/contacts-write.ts +5 -21
  97. package/src/context/compactor.ts +2 -2
  98. package/src/credential-execution/process-manager.ts +55 -14
  99. package/src/credential-execution/prompted-credential.ts +2 -3
  100. package/src/daemon/config-watcher.ts +0 -4
  101. package/src/daemon/conversation-agent-loop.ts +15 -4
  102. package/src/daemon/conversation-slash.ts +2 -23
  103. package/src/daemon/conversation-tool-setup.ts +11 -3
  104. package/src/daemon/conversation.ts +2 -0
  105. package/src/daemon/handlers/config-channels.ts +20 -16
  106. package/src/daemon/handlers/config-slack-channel.ts +2 -3
  107. package/src/daemon/lifecycle.ts +0 -7
  108. package/src/daemon/message-types/conversations.ts +3 -3
  109. package/src/daemon/message-types/sync.ts +0 -1
  110. package/src/daemon/orphan-reaper.test.ts +0 -19
  111. package/src/daemon/orphan-reaper.ts +2 -24
  112. package/src/daemon/server.ts +0 -10
  113. package/src/home/relationship-state.ts +2 -4
  114. package/src/memory/__tests__/memory-retrospective-job.test.ts +195 -401
  115. package/src/memory/bookmark-crud.ts +1 -2
  116. package/src/memory/db-init.ts +7 -17
  117. package/src/memory/embedding-backend.ts +23 -0
  118. package/src/memory/embedding-billing-breaker.ts +96 -0
  119. package/src/memory/jobs-store.ts +25 -13
  120. package/src/memory/jobs-worker.ts +52 -0
  121. package/src/memory/memory-retrospective-constants.ts +4 -4
  122. package/src/memory/memory-retrospective-job.ts +19 -227
  123. package/src/memory/migrations/291-contact-channels-renormalize-addresses.ts +62 -0
  124. package/src/memory/migrations/__tests__/291-contact-channels-renormalize-addresses.test.ts +311 -0
  125. package/src/memory/migrations/__tests__/run-migrations.test.ts +52 -0
  126. package/src/memory/migrations/index.ts +1 -0
  127. package/src/memory/migrations/run-migrations.ts +41 -0
  128. package/src/memory/migrations/validate-migration-state.ts +1 -1
  129. package/src/memory/schema/contacts.ts +0 -4
  130. package/src/messaging/providers/slack/adapter.ts +1 -1
  131. package/src/notifications/adapters/shared.ts +29 -0
  132. package/src/notifications/adapters/slack.ts +5 -32
  133. package/src/notifications/adapters/telegram.ts +2 -20
  134. package/src/notifications/broadcaster.ts +10 -1
  135. package/src/notifications/home-feed-side-effect.ts +4 -3
  136. package/src/notifications/notification-utils.ts +17 -19
  137. package/src/notifications/types.ts +7 -0
  138. package/src/oauth/AGENTS.md +5 -24
  139. package/src/plugin-api/constants.ts +1 -1
  140. package/src/plugin-api/index.ts +6 -1
  141. package/src/plugin-api/model-profiles.ts +33 -0
  142. package/src/plugin-api/types.ts +50 -2
  143. package/src/plugins/defaults/index.ts +25 -0
  144. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +54 -2
  145. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +107 -7
  146. package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +276 -0
  147. package/src/plugins/defaults/surface-completion-nudge/hooks/stop.ts +22 -0
  148. package/src/plugins/defaults/surface-completion-nudge/nudge-state-store.ts +46 -0
  149. package/src/plugins/defaults/surface-completion-nudge/package.json +14 -0
  150. package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +1 -1
  151. package/src/prompts/persona-resolver.ts +2 -2
  152. package/src/runtime/AGENTS.md +0 -1
  153. package/src/runtime/actor-trust-resolver.ts +12 -44
  154. package/src/runtime/btw-sidechain.ts +3 -6
  155. package/src/runtime/channel-approval-types.ts +18 -45
  156. package/src/runtime/channel-invite-transports/telegram.ts +4 -4
  157. package/src/runtime/channel-verification-service.ts +4 -3
  158. package/src/runtime/invite-redemption-service.ts +3 -3
  159. package/src/runtime/routes/__tests__/plugins-routes.test.ts +218 -1
  160. package/src/runtime/routes/app-routes.ts +1 -1
  161. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +2 -2
  162. package/src/runtime/routes/assets/vellum-design-system.css +1959 -0
  163. package/src/runtime/routes/btw-routes.ts +1 -27
  164. package/src/runtime/routes/conversation-compaction-routes.ts +1 -1
  165. package/src/runtime/routes/conversation-routes.ts +2 -2
  166. package/src/runtime/routes/credential-routes.ts +40 -16
  167. package/src/runtime/routes/empty-state-greeting-cache.ts +1 -2
  168. package/src/runtime/routes/identity-routes.ts +1 -296
  169. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +1 -1
  170. package/src/runtime/routes/plugins-routes.ts +171 -5
  171. package/src/runtime/routes/schedule-routes.ts +0 -22
  172. package/src/runtime/routes/workflow-routes.test.ts +4 -43
  173. package/src/runtime/routes/workflow-routes.ts +0 -28
  174. package/src/runtime/routes/workspace-greetings.ts +55 -0
  175. package/src/runtime/sync/resource-sync-events.ts +1 -11
  176. package/src/schedule/inference-profile.ts +2 -14
  177. package/src/subagent/manager.ts +6 -0
  178. package/src/subagent/types.ts +6 -0
  179. package/src/tools/AGENTS.md +3 -3
  180. package/src/tools/browser/browser-execution.ts +1 -1
  181. package/src/tools/network/web-search-error.ts +1 -1
  182. package/src/tools/permission-checker.ts +1 -1
  183. package/src/tools/schedule/create.ts +3 -9
  184. package/src/tools/schedule/update.ts +2 -10
  185. package/src/tools/side-effects.ts +2 -17
  186. package/src/tools/skills/execute.ts +34 -0
  187. package/src/tools/subagent/spawn.ts +34 -9
  188. package/src/tools/tool-approval-handler.ts +1 -3
  189. package/src/tools/tool-manifest.ts +0 -2
  190. package/src/tools/ui-surface/definitions.ts +39 -1
  191. package/src/tools/workflows/run-workflow.test.ts +8 -18
  192. package/src/util/platform.ts +2 -2
  193. package/src/workflows/capabilities.ts +2 -3
  194. package/src/workflows/run-manager.test.ts +0 -25
  195. package/src/workflows/run-manager.ts +2 -24
  196. package/src/__tests__/app-control-no-global-cgevent.test.ts +0 -98
  197. package/src/__tests__/credential-security-e2e.test.ts +0 -362
  198. package/src/__tests__/credential-vault-unit.test.ts +0 -1528
  199. package/src/__tests__/credential-vault.test.ts +0 -1706
  200. package/src/__tests__/identity-intro-cache.test.ts +0 -315
  201. package/src/__tests__/secret-onetime-send.test.ts +0 -182
  202. package/src/cli/commands/__tests__/task.test.ts +0 -914
  203. package/src/cli/commands/task.ts +0 -771
  204. package/src/config/bundled-skills/personal-page/SKILL.md +0 -57
  205. package/src/config/bundled-skills/personal-page/TOOLS.json +0 -27
  206. package/src/config/bundled-skills/personal-page/tools/app-refresh.ts +0 -17
  207. package/src/config/preloaded-apps/personal-page/src/components/About.tsx +0 -22
  208. package/src/config/preloaded-apps/personal-page/src/components/App.tsx +0 -16
  209. package/src/config/preloaded-apps/personal-page/src/components/Features.tsx +0 -77
  210. package/src/config/preloaded-apps/personal-page/src/components/Hero.tsx +0 -57
  211. package/src/config/preloaded-apps/personal-page/src/components/Pending.tsx +0 -28
  212. package/src/config/preloaded-apps/personal-page/src/components/animations.tsx +0 -234
  213. package/src/config/preloaded-apps/personal-page/src/components/icons.tsx +0 -48
  214. package/src/config/preloaded-apps/personal-page/src/components/media.ts +0 -16
  215. package/src/config/preloaded-apps/personal-page/src/index.html +0 -20
  216. package/src/config/preloaded-apps/personal-page/src/main.tsx +0 -7
  217. package/src/config/preloaded-apps/personal-page/src/profile-data.ts +0 -82
  218. package/src/config/preloaded-apps/personal-page/src/styles.css +0 -759
  219. package/src/memory/__tests__/preloaded-apps.test.ts +0 -85
  220. package/src/memory/preloaded-apps.ts +0 -116
  221. package/src/runtime/routes/identity-intro-cache.ts +0 -172
  222. package/src/tools/credentials/vault.ts +0 -712
@@ -0,0 +1,519 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdirSync, readdirSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ afterAll,
7
+ afterEach,
8
+ beforeAll,
9
+ beforeEach,
10
+ describe,
11
+ expect,
12
+ mock,
13
+ test,
14
+ } from "bun:test";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Mock logger
18
+ // ---------------------------------------------------------------------------
19
+
20
+ mock.module("../util/logger.js", () => ({
21
+ getLogger: () =>
22
+ new Proxy({} as Record<string, unknown>, {
23
+ get: () => () => {},
24
+ }),
25
+ }));
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Use encrypted backend with a temp store path
29
+ // ---------------------------------------------------------------------------
30
+
31
+ import { _resetBackend, setSecureKeyAsync } from "../security/secure-keys.js";
32
+ import { setStorePathForTesting } from "./encrypted-store-test-helpers.js";
33
+
34
+ const TEST_DIR = join(
35
+ tmpdir(),
36
+ `vellum-token-manager-test-${randomBytes(4).toString("hex")}`,
37
+ );
38
+ const STORE_PATH = join(TEST_DIR, "keys.enc");
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Mock OAuth2 token refresh so dedup can be observed without network I/O
42
+ // ---------------------------------------------------------------------------
43
+
44
+ let mockRefreshOAuth2Token: ReturnType<
45
+ typeof mock<
46
+ (
47
+ tokenExchangeUrl: string,
48
+ clientId: string,
49
+ refreshToken: string,
50
+ clientSecret?: string,
51
+ tokenEndpointAuthMethod?: string,
52
+ ) => Promise<{ accessToken: string; expiresIn: number }>
53
+ >
54
+ >;
55
+
56
+ mock.module("../security/oauth2.js", () => {
57
+ mockRefreshOAuth2Token = mock(() =>
58
+ Promise.resolve({
59
+ accessToken: "refreshed-access-token",
60
+ expiresIn: 3600,
61
+ }),
62
+ );
63
+ return {
64
+ refreshOAuth2Token: mockRefreshOAuth2Token,
65
+ };
66
+ });
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Mock oauth-store — token-manager reads refresh config from SQLite
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /** Mutable per-test map of provider connections for getConnectionByProvider */
73
+ const mockConnections = new Map<
74
+ string,
75
+ {
76
+ id: string;
77
+ provider: string;
78
+ oauthAppId: string;
79
+ expiresAt: number | null;
80
+ }
81
+ >();
82
+ const mockApps = new Map<
83
+ string,
84
+ {
85
+ id: string;
86
+ provider: string;
87
+ clientId: string;
88
+ clientSecretCredentialPath: string;
89
+ }
90
+ >();
91
+ const mockProviders = new Map<
92
+ string,
93
+ {
94
+ key: string;
95
+ tokenExchangeUrl: string;
96
+ refreshUrl?: string | null;
97
+ tokenEndpointAuthMethod?: string;
98
+ }
99
+ >();
100
+
101
+ mock.module("../oauth/oauth-store.js", () => ({
102
+ getConnectionByProvider: (service: string) => mockConnections.get(service),
103
+ getConnection: (id: string) => {
104
+ for (const conn of mockConnections.values()) {
105
+ if (conn.id === id) return conn;
106
+ }
107
+ return undefined;
108
+ },
109
+ getApp: (id: string) => mockApps.get(id),
110
+ getProvider: (key: string) => mockProviders.get(key),
111
+ updateConnection: () => {},
112
+ }));
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Import the modules under test
116
+ // ---------------------------------------------------------------------------
117
+
118
+ import {
119
+ _resetInflightRefreshes,
120
+ _resetRefreshBreakers,
121
+ withValidToken,
122
+ } from "../security/token-manager.js";
123
+ import { _setMetadataPath } from "../tools/credentials/metadata-store.js";
124
+
125
+ describe("withValidToken refresh deduplication", () => {
126
+ beforeAll(() => {
127
+ mkdirSync(TEST_DIR, { recursive: true });
128
+ });
129
+
130
+ beforeEach(() => {
131
+ _resetBackend();
132
+ for (const entry of readdirSync(TEST_DIR)) {
133
+ rmSync(join(TEST_DIR, entry), { recursive: true, force: true });
134
+ }
135
+ setStorePathForTesting(STORE_PATH);
136
+ _setMetadataPath(join(TEST_DIR, "metadata.json"));
137
+ _resetRefreshBreakers();
138
+ _resetInflightRefreshes();
139
+ mockRefreshOAuth2Token.mockClear();
140
+ // Clear mock oauth-store maps
141
+ mockConnections.clear();
142
+ mockApps.clear();
143
+ mockProviders.clear();
144
+ });
145
+
146
+ afterEach(() => {
147
+ _setMetadataPath(null);
148
+ setStorePathForTesting(null);
149
+ _resetBackend();
150
+ _resetRefreshBreakers();
151
+ _resetInflightRefreshes();
152
+ mockConnections.clear();
153
+ mockApps.clear();
154
+ mockProviders.clear();
155
+ });
156
+
157
+ afterAll(() => {
158
+ rmSync(TEST_DIR, { recursive: true, force: true });
159
+ });
160
+
161
+ /**
162
+ * Helper: set up a service with an access token, refresh token, and
163
+ * mock DB data so that token refresh can proceed through doRefresh().
164
+ *
165
+ * OAuth-specific fields (tokenExchangeUrl, clientId, expiresAt) are stored
166
+ * in the SQLite oauth-store. The mock maps simulate the DB layer.
167
+ */
168
+ async function setupService(
169
+ service: string,
170
+ opts?: { expired?: boolean; accessToken?: string },
171
+ ) {
172
+ const accessToken = opts?.accessToken ?? "old-access-token";
173
+
174
+ // Seed mock oauth-store maps so token-manager can resolve refresh config
175
+ const appId = `app-${service}`;
176
+ const connId = `conn-${service}`;
177
+
178
+ // Store access token under the oauth_connection key path that
179
+ // withValidToken reads (not the legacy credentialKey path).
180
+ await setSecureKeyAsync(
181
+ `oauth_connection/${connId}/access_token`,
182
+ accessToken,
183
+ );
184
+ mockProviders.set(service, {
185
+ key: service,
186
+ tokenExchangeUrl: "https://oauth.example.com/token",
187
+ refreshUrl: null,
188
+ });
189
+ mockApps.set(appId, {
190
+ id: appId,
191
+ provider: service,
192
+ clientId: "test-client-id",
193
+ clientSecretCredentialPath: `oauth_app/${appId}/client_secret`,
194
+ });
195
+ mockConnections.set(service, {
196
+ id: connId,
197
+ provider: service,
198
+ oauthAppId: appId,
199
+ expiresAt: opts?.expired
200
+ ? Date.now() - 60_000 // expired 1 minute ago
201
+ : Date.now() + 3600_000, // expires in 1 hour
202
+ });
203
+ // Store refresh token and client_secret in secure keys (token-manager reads them)
204
+ await setSecureKeyAsync(
205
+ `oauth_connection/${connId}/refresh_token`,
206
+ "valid-refresh-token",
207
+ );
208
+ await setSecureKeyAsync(
209
+ `oauth_app/${appId}/client_secret`,
210
+ "test-client-secret",
211
+ );
212
+ }
213
+
214
+ test("3 concurrent 401 refreshes for the same service call doRefresh exactly once", async () => {
215
+ await setupService("google");
216
+
217
+ let resolveRefresh!: (value: {
218
+ accessToken: string;
219
+ expiresIn: number;
220
+ }) => void;
221
+ const refreshPromise = new Promise<{
222
+ accessToken: string;
223
+ expiresIn: number;
224
+ }>((resolve) => {
225
+ resolveRefresh = resolve;
226
+ });
227
+
228
+ mockRefreshOAuth2Token.mockImplementation(() => refreshPromise);
229
+
230
+ const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
231
+
232
+ const callback = async (token: string) => {
233
+ if (token === "old-access-token") throw err401;
234
+ return `result-with-${token}`;
235
+ };
236
+
237
+ // Launch 3 concurrent withValidToken calls — all will get a non-expired
238
+ // token first, call the callback, get a 401, and then try to refresh.
239
+ const p1 = withValidToken("google", callback);
240
+ const p2 = withValidToken("google", callback);
241
+ const p3 = withValidToken("google", callback);
242
+
243
+ // Let the event loop tick so all 3 calls enter the 401 retry path
244
+ await new Promise((r) => setTimeout(r, 10));
245
+
246
+ // Resolve the single refresh attempt
247
+ resolveRefresh({ accessToken: "new-token-123", expiresIn: 3600 });
248
+
249
+ const results = await Promise.all([p1, p2, p3]);
250
+
251
+ // All 3 should succeed with the refreshed token
252
+ expect(results).toEqual([
253
+ "result-with-new-token-123",
254
+ "result-with-new-token-123",
255
+ "result-with-new-token-123",
256
+ ]);
257
+
258
+ // refreshOAuth2Token should have been called exactly once
259
+ expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(1);
260
+ });
261
+
262
+ test("concurrent refreshes for different services proceed independently", async () => {
263
+ await setupService("google");
264
+ await setupService("slack");
265
+
266
+ let resolveGmail!: (value: {
267
+ accessToken: string;
268
+ expiresIn: number;
269
+ }) => void;
270
+ let resolveSlack!: (value: {
271
+ accessToken: string;
272
+ expiresIn: number;
273
+ }) => void;
274
+
275
+ const gmailPromise = new Promise<{
276
+ accessToken: string;
277
+ expiresIn: number;
278
+ }>((resolve) => {
279
+ resolveGmail = resolve;
280
+ });
281
+ const slackPromise = new Promise<{
282
+ accessToken: string;
283
+ expiresIn: number;
284
+ }>((resolve) => {
285
+ resolveSlack = resolve;
286
+ });
287
+
288
+ let refreshCallCount = 0;
289
+ mockRefreshOAuth2Token.mockImplementation(() => {
290
+ refreshCallCount++;
291
+ // Both services use the same tokenExchangeUrl in this test, so we track by
292
+ // call order to return the correct deferred promise.
293
+ if (refreshCallCount === 1) return gmailPromise;
294
+ return slackPromise;
295
+ });
296
+
297
+ const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
298
+
299
+ const gmailCallback = async (token: string) => {
300
+ if (token === "old-access-token") throw err401;
301
+ return `gmail-${token}`;
302
+ };
303
+ const slackCallback = async (token: string) => {
304
+ if (token === "old-access-token") throw err401;
305
+ return `slack-${token}`;
306
+ };
307
+
308
+ const p1 = withValidToken("google", gmailCallback);
309
+ const p2 = withValidToken("slack", slackCallback);
310
+
311
+ await new Promise((r) => setTimeout(r, 10));
312
+
313
+ // Resolve both independently
314
+ resolveGmail({ accessToken: "gmail-new-token", expiresIn: 3600 });
315
+ resolveSlack({ accessToken: "slack-new-token", expiresIn: 3600 });
316
+
317
+ const [r1, r2] = await Promise.all([p1, p2]);
318
+
319
+ expect(r1).toBe("gmail-gmail-new-token");
320
+ expect(r2).toBe("slack-slack-new-token");
321
+
322
+ // Both services should have triggered their own refresh
323
+ expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(2);
324
+ });
325
+
326
+ test("deduplication cleans up after refresh completes, allowing subsequent refreshes", async () => {
327
+ await setupService("google");
328
+
329
+ let refreshCount = 0;
330
+ mockRefreshOAuth2Token.mockImplementation(() => {
331
+ refreshCount++;
332
+ return Promise.resolve({
333
+ accessToken: `token-${refreshCount}`,
334
+ expiresIn: 3600,
335
+ });
336
+ });
337
+
338
+ const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
339
+
340
+ // First call triggers a refresh (old token → 401 → refresh → token-1)
341
+ const r1 = await withValidToken("google", async (token: string) => {
342
+ if (token !== "token-1") throw err401;
343
+ return token;
344
+ });
345
+ expect(r1).toBe("token-1");
346
+ expect(refreshCount).toBe(1);
347
+
348
+ // Second call also triggers a 401 to verify dedup state was cleaned up
349
+ // and a new refresh is allowed (not deduplicated with the first).
350
+ const r2 = await withValidToken("google", async (token: string) => {
351
+ if (token !== "token-2") throw err401;
352
+ return token;
353
+ });
354
+ expect(r2).toBe("token-2");
355
+ // Second refresh should have happened (not deduplicated with the first,
356
+ // since the first already completed)
357
+ expect(refreshCount).toBe(2);
358
+ });
359
+
360
+ test("deduplication propagates refresh errors to all waiting callers", async () => {
361
+ await setupService("google");
362
+
363
+ mockRefreshOAuth2Token.mockImplementation(() =>
364
+ Promise.reject(
365
+ Object.assign(
366
+ new Error("OAuth2 token refresh failed (HTTP 401: invalid_grant)"),
367
+ ),
368
+ ),
369
+ );
370
+
371
+ const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
372
+
373
+ const callback = async (token: string) => {
374
+ if (token === "old-access-token") throw err401;
375
+ return "should-not-reach";
376
+ };
377
+
378
+ // Launch 2 concurrent calls — both should fail with the same error
379
+ const p1 = withValidToken("google", callback);
380
+ const p2 = withValidToken("google", callback);
381
+
382
+ const results = await Promise.allSettled([p1, p2]);
383
+
384
+ expect(results[0].status).toBe("rejected");
385
+ expect(results[1].status).toBe("rejected");
386
+
387
+ // Only one actual refresh attempt
388
+ expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(1);
389
+ });
390
+
391
+ // -----------------------------------------------------------------------
392
+ // refreshUrl resolution — provider.refreshUrl with fallback to tokenExchangeUrl
393
+ // -----------------------------------------------------------------------
394
+ describe("refreshUrl resolution", () => {
395
+ test("uses provider.refreshUrl when set", async () => {
396
+ await setupService("google");
397
+ mockProviders.get("google")!.refreshUrl =
398
+ "https://refresh.example.com/token";
399
+
400
+ mockRefreshOAuth2Token.mockImplementation(() =>
401
+ Promise.resolve({
402
+ accessToken: "new-token-from-refresh-url",
403
+ expiresIn: 3600,
404
+ }),
405
+ );
406
+
407
+ const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
408
+
409
+ const callback = async (token: string) => {
410
+ if (token === "old-access-token") throw err401;
411
+ return `result-with-${token}`;
412
+ };
413
+
414
+ const result = await withValidToken("google", callback);
415
+
416
+ expect(result).toBe("result-with-new-token-from-refresh-url");
417
+ expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(1);
418
+ // Assert the refresh endpoint passed in is provider.refreshUrl, not
419
+ // the tokenExchangeUrl fallback.
420
+ expect(mockRefreshOAuth2Token.mock.calls[0]?.[0]).toBe(
421
+ "https://refresh.example.com/token",
422
+ );
423
+ });
424
+
425
+ test("falls back to provider.tokenExchangeUrl when refreshUrl is null", async () => {
426
+ // setupService sets refreshUrl: null by default — this exercises the
427
+ // fallback path explicitly.
428
+ await setupService("google");
429
+ expect(mockProviders.get("google")!.refreshUrl).toBeNull();
430
+
431
+ mockRefreshOAuth2Token.mockImplementation(() =>
432
+ Promise.resolve({
433
+ accessToken: "new-token-from-token-exchange-url",
434
+ expiresIn: 3600,
435
+ }),
436
+ );
437
+
438
+ const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
439
+
440
+ const callback = async (token: string) => {
441
+ if (token === "old-access-token") throw err401;
442
+ return `result-with-${token}`;
443
+ };
444
+
445
+ const result = await withValidToken("google", callback);
446
+
447
+ expect(result).toBe("result-with-new-token-from-token-exchange-url");
448
+ expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(1);
449
+ // Assert the refresh endpoint falls back to tokenExchangeUrl.
450
+ expect(mockRefreshOAuth2Token.mock.calls[0]?.[0]).toBe(
451
+ "https://oauth.example.com/token",
452
+ );
453
+ });
454
+
455
+ test("falls back to provider.tokenExchangeUrl when refreshUrl is undefined", async () => {
456
+ await setupService("google");
457
+ // Delete the refreshUrl field entirely so the property is `undefined`
458
+ // rather than `null`. Both representations of "not set" must produce
459
+ // the fallback behavior.
460
+ delete mockProviders.get("google")!.refreshUrl;
461
+ expect(mockProviders.get("google")!.refreshUrl).toBeUndefined();
462
+
463
+ mockRefreshOAuth2Token.mockImplementation(() =>
464
+ Promise.resolve({
465
+ accessToken: "new-token-from-token-exchange-url",
466
+ expiresIn: 3600,
467
+ }),
468
+ );
469
+
470
+ const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
471
+
472
+ const callback = async (token: string) => {
473
+ if (token === "old-access-token") throw err401;
474
+ return `result-with-${token}`;
475
+ };
476
+
477
+ const result = await withValidToken("google", callback);
478
+
479
+ expect(result).toBe("result-with-new-token-from-token-exchange-url");
480
+ expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(1);
481
+ // Assert the refresh endpoint falls back to tokenExchangeUrl.
482
+ expect(mockRefreshOAuth2Token.mock.calls[0]?.[0]).toBe(
483
+ "https://oauth.example.com/token",
484
+ );
485
+ });
486
+
487
+ test("falls back to provider.tokenExchangeUrl when refreshUrl is empty string", async () => {
488
+ // Platform's Python `oauth_app.refresh_url or oauth_app.token_exchange_url`
489
+ // treats an empty string as unset. We use `||` (not `??`) so empty
490
+ // strings follow the same fallback path and never resolve to an empty
491
+ // endpoint.
492
+ await setupService("google");
493
+ mockProviders.get("google")!.refreshUrl = "";
494
+
495
+ mockRefreshOAuth2Token.mockImplementation(() =>
496
+ Promise.resolve({
497
+ accessToken: "new-token-from-token-exchange-url",
498
+ expiresIn: 3600,
499
+ }),
500
+ );
501
+
502
+ const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
503
+
504
+ const callback = async (token: string) => {
505
+ if (token === "old-access-token") throw err401;
506
+ return `result-with-${token}`;
507
+ };
508
+
509
+ const result = await withValidToken("google", callback);
510
+
511
+ expect(result).toBe("result-with-new-token-from-token-exchange-url");
512
+ expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(1);
513
+ // Assert the refresh endpoint falls back to tokenExchangeUrl — NOT "".
514
+ expect(mockRefreshOAuth2Token.mock.calls[0]?.[0]).toBe(
515
+ "https://oauth.example.com/token",
516
+ );
517
+ });
518
+ });
519
+ });
@@ -587,36 +587,6 @@ describe("isSideEffectTool", () => {
587
587
  expect(isSideEffectTool("nonexistent_tool")).toBe(false);
588
588
  expect(isSideEffectTool("")).toBe(false);
589
589
  });
590
-
591
- describe("action-aware classification for mixed-action tools", () => {
592
- test("credential_store store is a side-effect", () => {
593
- expect(isSideEffectTool("credential_store", { action: "store" })).toBe(
594
- true,
595
- );
596
- });
597
-
598
- test("credential_store delete is a side-effect", () => {
599
- expect(isSideEffectTool("credential_store", { action: "delete" })).toBe(
600
- true,
601
- );
602
- });
603
-
604
- test("credential_store prompt is a side-effect", () => {
605
- expect(isSideEffectTool("credential_store", { action: "prompt" })).toBe(
606
- true,
607
- );
608
- });
609
-
610
- test("credential_store list is NOT a side-effect", () => {
611
- expect(isSideEffectTool("credential_store", { action: "list" })).toBe(
612
- false,
613
- );
614
- });
615
-
616
- test("credential_store without input is NOT a side-effect", () => {
617
- expect(isSideEffectTool("credential_store")).toBe(false);
618
- });
619
- });
620
590
  });
621
591
 
622
592
  // ---------------------------------------------------------------------------
@@ -751,10 +721,6 @@ describe("ToolExecutor forcePromptSideEffects enforcement", () => {
751
721
  { name: "document_create", input: { title: "doc", content: "body" } },
752
722
  { name: "document_update", input: { id: "doc-1", content: "updated" } },
753
723
  { name: "document_delete", input: { surface_id: "doc-1" } },
754
- {
755
- name: "credential_store",
756
- input: { action: "store", name: "api-key", value: "secret" },
757
- },
758
724
  ];
759
725
 
760
726
  for (const { name, input } of sideEffectTools) {
@@ -817,51 +783,6 @@ describe("ToolExecutor forcePromptSideEffects enforcement", () => {
817
783
  expect(promptCalled).toBe(true);
818
784
  });
819
785
 
820
- // ── Credential store action-aware (PR fix9) ──────────
821
-
822
- test("credential_store store forces prompt under forcePromptSideEffects", async () => {
823
- checkResultOverride = { decision: "allow", reason: "Matched trust rule" };
824
-
825
- const executor = new ToolExecutor(makeTrackingPrompter());
826
- const result = await executor.execute(
827
- "credential_store",
828
- { action: "store", name: "api-key", value: "sk-secret-123" },
829
- makeContext({ forcePromptSideEffects: true }),
830
- );
831
-
832
- expect(result.isError).toBe(false);
833
- expect(promptCalled).toBe(true);
834
- });
835
-
836
- test("credential_store delete forces prompt under forcePromptSideEffects", async () => {
837
- checkResultOverride = { decision: "allow", reason: "Matched trust rule" };
838
-
839
- const executor = new ToolExecutor(makeTrackingPrompter());
840
- const result = await executor.execute(
841
- "credential_store",
842
- { action: "delete", name: "api-key" },
843
- makeContext({ forcePromptSideEffects: true }),
844
- );
845
-
846
- expect(result.isError).toBe(false);
847
- expect(promptCalled).toBe(true);
848
- });
849
-
850
- test("credential_store list does NOT force prompt under forcePromptSideEffects", async () => {
851
- checkResultOverride = { decision: "allow", reason: "Matched trust rule" };
852
-
853
- const executor = new ToolExecutor(makeTrackingPrompter());
854
- const result = await executor.execute(
855
- "credential_store",
856
- { action: "list" },
857
- makeContext({ forcePromptSideEffects: true }),
858
- );
859
-
860
- expect(result.isError).toBe(false);
861
- // list is read-only — must NOT trigger forced prompting
862
- expect(promptCalled).toBe(false);
863
- });
864
-
865
786
  // ── Workspace mode + forcePromptSideEffects interaction ──────────
866
787
 
867
788
  test("workspace mode allow → prompt promotion still works for side-effect tools under forcePromptSideEffects", async () => {
@@ -266,7 +266,7 @@ for (const config of CHANNEL_CONFIGS) {
266
266
 
267
267
  const contactResult = findContactChannel({
268
268
  channelType: config.channel,
269
- externalUserId: config.senderExternalUserId,
269
+ address: config.senderExternalUserId,
270
270
  });
271
271
 
272
272
  expect(contactResult).not.toBeNull();
@@ -288,7 +288,7 @@ for (const config of CHANNEL_CONFIGS) {
288
288
  // Should be found on this channel
289
289
  const sameChanResult = findContactChannel({
290
290
  channelType: config.channel,
291
- externalUserId: config.senderExternalUserId,
291
+ address: config.senderExternalUserId,
292
292
  });
293
293
  expect(sameChanResult).not.toBeNull();
294
294
 
@@ -296,7 +296,7 @@ for (const config of CHANNEL_CONFIGS) {
296
296
  const otherChannel = config.channel === "telegram" ? "slack" : "telegram";
297
297
  const crossChanResult = findContactChannel({
298
298
  channelType: otherChannel,
299
- externalUserId: config.senderExternalUserId,
299
+ address: config.senderExternalUserId,
300
300
  });
301
301
  expect(crossChanResult).toBeNull();
302
302
  });
@@ -102,7 +102,7 @@ describe("trusted contact verification → member activation", () => {
102
102
  // Verify: active member record exists
103
103
  const contactResult = findContactChannel({
104
104
  channelType: "telegram",
105
- externalUserId: "requester-user-123",
105
+ address: "requester-user-123",
106
106
  });
107
107
 
108
108
  expect(contactResult).not.toBeNull();
@@ -236,7 +236,7 @@ describe("trusted contact verification → member activation", () => {
236
236
  // Simulate the ACL check that inbound-message-handler performs
237
237
  const contactResult = findContactChannel({
238
238
  channelType: "telegram",
239
- externalUserId: "requester-user-456",
239
+ address: "requester-user-456",
240
240
  });
241
241
 
242
242
  expect(contactResult).not.toBeNull();
@@ -274,7 +274,7 @@ describe("trusted contact verification → member activation", () => {
274
274
  // Member should be found via contacts
275
275
  const contactResult = findContactChannel({
276
276
  channelType: "telegram",
277
- externalUserId: "user-cross-test",
277
+ address: "user-cross-test",
278
278
  });
279
279
  expect(contactResult).not.toBeNull();
280
280
  expect(contactResult!.channel.status).toBe("active");
@@ -282,7 +282,7 @@ describe("trusted contact verification → member activation", () => {
282
282
  // Member should NOT be found for a different channel type
283
283
  const otherChannel = findContactChannel({
284
284
  channelType: "slack",
285
- externalUserId: "user-cross-test",
285
+ address: "user-cross-test",
286
286
  });
287
287
  expect(otherChannel).toBeNull();
288
288
  });
@@ -306,7 +306,7 @@ describe("trusted contact verification → member activation", () => {
306
306
  // Verify the member is indeed revoked (ACL would reject)
307
307
  const revokedResult = findContactChannel({
308
308
  channelType: "telegram",
309
- externalUserId: "user-revoked",
309
+ address: "user-revoked",
310
310
  });
311
311
  expect(revokedResult).not.toBeNull();
312
312
  expect(revokedResult!.channel.status).toBe("revoked");
@@ -345,7 +345,7 @@ describe("trusted contact verification → member activation", () => {
345
345
  // Verify: member is now active again
346
346
  const reactivatedResult = findContactChannel({
347
347
  channelType: "telegram",
348
- externalUserId: "user-revoked",
348
+ address: "user-revoked",
349
349
  });
350
350
  expect(reactivatedResult).not.toBeNull();
351
351
  expect(reactivatedResult!.channel.status).toBe("active");