@vellumai/assistant 0.5.4 → 0.5.5

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 (59) hide show
  1. package/Dockerfile +18 -27
  2. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
  3. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  6. package/src/__tests__/openai-whisper.test.ts +93 -0
  7. package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
  8. package/src/__tests__/volume-security-guard.test.ts +155 -0
  9. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
  10. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
  11. package/src/config/env-registry.ts +9 -0
  12. package/src/config/feature-flag-registry.json +8 -0
  13. package/src/credential-execution/managed-catalog.ts +5 -15
  14. package/src/daemon/config-watcher.ts +4 -1
  15. package/src/daemon/daemon-control.ts +7 -0
  16. package/src/daemon/lifecycle.ts +7 -1
  17. package/src/daemon/providers-setup.ts +2 -1
  18. package/src/hooks/manager.ts +7 -0
  19. package/src/instrument.ts +33 -1
  20. package/src/memory/embedding-local.ts +11 -5
  21. package/src/memory/job-handlers/conversation-starters.ts +24 -36
  22. package/src/messaging/provider.ts +9 -0
  23. package/src/messaging/providers/slack/adapter.ts +29 -2
  24. package/src/oauth/connection-resolver.test.ts +22 -18
  25. package/src/oauth/connection-resolver.ts +92 -7
  26. package/src/oauth/platform-connection.test.ts +78 -69
  27. package/src/oauth/platform-connection.ts +12 -19
  28. package/src/permissions/trust-client.ts +343 -0
  29. package/src/permissions/trust-store-interface.ts +105 -0
  30. package/src/permissions/trust-store.ts +523 -36
  31. package/src/platform/client.test.ts +148 -0
  32. package/src/platform/client.ts +71 -0
  33. package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
  34. package/src/providers/speech-to-text/openai-whisper.ts +68 -0
  35. package/src/providers/speech-to-text/resolve.ts +9 -0
  36. package/src/providers/speech-to-text/types.ts +17 -0
  37. package/src/runtime/http-server.ts +2 -2
  38. package/src/runtime/routes/inbound-message-handler.ts +27 -3
  39. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
  40. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
  41. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
  42. package/src/runtime/routes/log-export-routes.ts +1 -0
  43. package/src/runtime/routes/secret-routes.ts +4 -1
  44. package/src/security/ces-credential-client.ts +173 -0
  45. package/src/security/secure-keys.ts +65 -22
  46. package/src/signals/bash.ts +3 -0
  47. package/src/signals/cancel.ts +3 -0
  48. package/src/signals/confirm.ts +3 -0
  49. package/src/signals/conversation-undo.ts +3 -0
  50. package/src/signals/event-stream.ts +7 -0
  51. package/src/signals/shotgun.ts +3 -0
  52. package/src/signals/trust-rule.ts +3 -0
  53. package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
  54. package/src/telemetry/usage-telemetry-reporter.ts +21 -19
  55. package/src/util/device-id.ts +70 -7
  56. package/src/util/logger.ts +35 -9
  57. package/src/util/platform.ts +29 -5
  58. package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
  59. package/src/workspace/migrations/registry.ts +2 -0
@@ -176,11 +176,11 @@ async function generateStarters(scopeId: string): Promise<GeneratedStarter[]> {
176
176
  ? truncate(rawIdentityContext, 2000, "\n…[truncated]")
177
177
  : null;
178
178
 
179
- const systemPrompt = `You are generating 4 conversation starters for a personal assistant app. These appear as clickable chips on the empty conversation page — the first thing the user sees when they open the app.
179
+ const systemPrompt = `You are generating 4 conversation starters for a personal assistant app. These appear as clickable chips on the empty conversation page — the first thing the user sees when they open the app. Clicking a chip sends its prompt as a message from the user.
180
180
 
181
181
  ${timeContext}
182
182
 
183
- Your goal: look at what's going on in this person's life right now and suggest the 4 most useful things they could ask you to do. Think about what a thoughtful chief of staff would proactively bring up in a 30-second check-in.
183
+ Your goal: suggest the 4 most useful things this person could ask you to do right now.
184
184
 
185
185
  ${identityContext ? `## Assistant identity & user profile\n\n${identityContext}\n\n` : ""}## What you know
186
186
 
@@ -188,7 +188,9 @@ ${rollup}
188
188
  ${diff}
189
189
  ${skills}
190
190
 
191
- ## How to think about this
191
+ ## Selection
192
+
193
+ Generate exactly 4 starters, ranked #1 (best) to #4.
192
194
 
193
195
  Start from the user's situation, not from the skill list. Ask yourself:
194
196
  - What is this person likely dealing with right now (given the day/time and their context)?
@@ -197,11 +199,7 @@ Start from the user's situation, not from the skill list. Ask yourself:
197
199
 
198
200
  The skills list tells you what the assistant CAN do — use it to filter out suggestions the assistant can't actually help with, not as a menu to generate suggestions from.
199
201
 
200
- ## Selection
201
-
202
- Generate exactly 4 starters, ranked #1 (best) to #4.
203
-
204
- For each, you must be able to clearly answer:
202
+ For each starter, you must clearly answer:
205
203
  - Why now? (timing — day of week, recent activity, upcoming deadline)
206
204
  - Why this user? (grounded in their specific context, not generic)
207
205
  - Why would they be glad I suggested this? (genuine usefulness, not just relevance)
@@ -218,44 +216,34 @@ Favor what is live over what is merely true. Recent changes matter more than old
218
216
 
219
217
  ## Output format
220
218
 
221
- Return exactly 4 starters in rank order (best first).
222
-
223
219
  Each starter has:
224
- - label: 3-6 words, max 40 chars, starts with a verb. Should read as something the user wants to do these chips send a message as the user, so the label must be in the user's voice. Must sound natural when read aloud.
225
- - prompt: 1-2 natural sentences, written as the user would actually say them — not templated.
220
+ - label: 3-6 words, max 40 chars, starts with a verb. Written in the user's voicesomething they'd want to do, not something the assistant is offering.
221
+ - prompt: 1-2 natural sentences, as the user would actually say them.
226
222
  - category: one of ${CONVERSATION_STARTER_CATEGORIES.join(", ")}
227
223
 
228
- The 4 starters should feel like one coherent set of recommendations for this moment — similar abstraction level, no jarring mix of mundane chores and life strategy. Don't lift raw memory phrases, project names, or jargon into labels unless they already sound natural in conversation.
229
-
230
- Never include a chip whose primary meaning is configuration, setup, workflow creation, or "set up X for Y" unless it solves an urgent pain the user is actively feeling right now. Prefer the outcome over the mechanism — "Catch the emails that matter" beats "Set up a playbook for inbox."
231
-
232
- ## Topic diversity
233
-
234
- Each chip should cover a distinct topic or concern. Never have two chips about the same tool, project, or theme — even if there are multiple related issues. Pick the single most impactful angle and give the other slot to something different. Four chips about three topics is too narrow; four chips about four topics is right.
224
+ ## Constraints
235
225
 
236
- ## User-facingness check
226
+ **Voice**: The user clicks these chips to send a message. Every label must read as something the user is asking to do, never something the assistant is saying to the user.
237
227
 
238
- If a label sounds like an issue title, project ticket, or implementation task, rewrite it. Prefer the user-visible payoff over the internal object name. The chip should feel inviting and useful, not merely accurate.
228
+ **Coherence**: The 4 starters should feel like one set similar abstraction level, no jarring mix of mundane chores and life strategy.
239
229
 
240
- Prefer natural, flowing language over mechanical or operational phrasing. "Get Slack messages flowing" is better than "Restore outgoing Slack messages." The label should sound like something a helpful person would say, not a support ticket.
230
+ **Diversity**: Each chip covers a distinct topic. Never two chips about the same tool, project, or theme. Four topics, four chips.
241
231
 
242
- Voice: The user clicks these chips to send a message. Every label must make sense as something the user is asking to do, never something the assistant is saying to the user.
232
+ **No setup chips**: Never include a chip whose primary meaning is configuration or "set up X for Y" unless it solves an urgent pain the user is actively feeling. Prefer the outcome over the mechanism.
243
233
 
244
- Before finalizing each label, ask yourself: would this feel good to click? Or does it sound like a backlog item? If it sounds like a backlog item, rewrite it.
234
+ **Natural language**: No jargon, project names, or raw memory phrases in labels unless they already sound natural in conversation. If a label sounds like a ticket title or backlog item, rewrite it as something the user would actually say.
245
235
 
246
- Examples of bad vs good:
247
- - BAD: "Fix Slack Socket Mode blocker" → GOOD: "Fix Slack so it just works"
248
- - BAD: "Rewire messaging for Socket Mode" → GOOD: "Get Socket Mode stable"
249
- - BAD: "Review this week's calendar" → GOOD: "Protect this week's focus"
250
- - BAD: "Model the coaching transition" → GOOD: "Plan the coaching transition"
251
- - BAD: "Restore outgoing Slack messages" → GOOD: "Get Slack messages flowing"
252
- - BAD: "Set up a playbook for inbox" → GOOD: "Catch the emails that matter"
236
+ ## Examples
253
237
 
254
- Assistant-voice vs user-voice:
255
- - BAD: "You've got a busy week ahead" → GOOD: "Plan my week ahead"
256
- - BAD: "Let me check your calendar" → GOOD: "Check my Thursday schedule"
238
+ Bad → Good (ticket-speak natural):
239
+ - "Fix Slack Socket Mode blocker" → "Fix Slack so it just works"
240
+ - "Restore outgoing Slack messages" → "Get Slack messages flowing"
241
+ - "Review this week's calendar" → "Protect this week's focus"
242
+ - "Set up a playbook for inbox" → "Triage my inbox"
257
243
 
258
- The good versions emphasize the user's payoff in the user's own voice, not the internal mechanism or the assistant's perspective.`;
244
+ Bad Good (assistant voice user voice):
245
+ - "You've got a busy week ahead" → "Plan my week ahead"
246
+ - "Let me check your calendar" → "Check my Thursday schedule"`;
259
247
 
260
248
  const { signal, cleanup } = createTimeout(20000);
261
249
  try {
@@ -280,7 +268,7 @@ The good versions emphasize the user's payoff in the user's own voice, not the i
280
268
  label: {
281
269
  type: "string",
282
270
  description:
283
- "User-voice chip text (2-7 words, max 40 chars, starts with a verb)",
271
+ "User-voice chip label (2-7 words, max 40 chars, verb-first)",
284
272
  },
285
273
  prompt: {
286
274
  type: "string",
@@ -89,6 +89,15 @@ export interface MessagingProvider {
89
89
  */
90
90
  isConnected?(): Promise<boolean>;
91
91
 
92
+ /**
93
+ * Custom credential resolution for providers with non-standard credential
94
+ * paths (e.g. Slack Socket Mode stores tokens under "slack_channel" rather
95
+ * than the OAuth provider key). When present, getProviderConnection() calls
96
+ * this instead of resolveOAuthConnection(), giving the provider full control
97
+ * over credential lookup including fallback strategies.
98
+ */
99
+ resolveConnection?(account?: string): Promise<OAuthConnection | string>;
100
+
92
101
  /** Platform-specific capabilities for tool routing (e.g. 'reactions', 'threads', 'labels'). */
93
102
  capabilities: Set<string>;
94
103
  }
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * Slack messaging provider adapter.
3
3
  *
4
- * Maps Slack API responses to the platform-agnostic messaging types
5
- * and implements the MessagingProvider interface.
4
+ * Maps Slack API responses to the platform-agnostic messaging types and
5
+ * implements the MessagingProvider interface.
6
6
  */
7
7
 
8
8
  import type { OAuthConnection } from "../../../oauth/connection.js";
9
+ import { resolveOAuthConnection } from "../../../oauth/connection-resolver.js";
10
+ import { isProviderConnected } from "../../../oauth/oauth-store.js";
11
+ import { credentialKey } from "../../../security/credential-key.js";
12
+ import { getSecureKeyAsync } from "../../../security/secure-keys.js";
9
13
  import type { MessagingProvider } from "../../provider.js";
10
14
  import type {
11
15
  ConnectionInfo,
@@ -112,6 +116,29 @@ export const slackProvider: MessagingProvider = {
112
116
  credentialService: "integration:slack",
113
117
  capabilities: new Set(["reactions", "threads", "leave_channel"]),
114
118
 
119
+ async isConnected(): Promise<boolean> {
120
+ // Socket Mode: check for bot token directly in credential store.
121
+ // The token is the source of truth; the slack_channel connection row
122
+ // is advisory (backfill can fail non-fatally on startup).
123
+ const botToken = await getSecureKeyAsync(
124
+ credentialKey("slack_channel", "bot_token"),
125
+ );
126
+ if (botToken) return true;
127
+ // Preserve existing OAuth path (integration:slack) for backwards compat.
128
+ return isProviderConnected("integration:slack");
129
+ },
130
+
131
+ async resolveConnection(account?: string): Promise<OAuthConnection | string> {
132
+ // Socket Mode: return raw bot token if available.
133
+ // Token presence is sufficient — no connection row required.
134
+ const botToken = await getSecureKeyAsync(
135
+ credentialKey("slack_channel", "bot_token"),
136
+ );
137
+ if (botToken) return botToken;
138
+ // Preserve existing OAuth path (integration:slack) for backwards compat.
139
+ return resolveOAuthConnection("integration:slack", { account });
140
+ },
141
+
115
142
  async testConnection(
116
143
  connectionOrToken: OAuthConnection | string,
117
144
  ): Promise<ConnectionInfo> {
@@ -8,12 +8,7 @@ let mockProvider: Record<string, unknown> | undefined;
8
8
  let mockConnection: Record<string, unknown> | undefined;
9
9
  let mockAccessToken: string | undefined;
10
10
  let mockConfig: Record<string, unknown> = {};
11
- let mockManagedProxyCtx = {
12
- enabled: false,
13
- platformBaseUrl: "",
14
- assistantApiKey: "",
15
- };
16
- let mockAssistantId = "";
11
+ let mockPlatformClient: Record<string, unknown> | null = null;
17
12
 
18
13
  // ---------------------------------------------------------------------------
19
14
  // Module mocks (must precede imports of the module under test)
@@ -48,12 +43,10 @@ mock.module("../config/loader.js", () => ({
48
43
  getConfig: () => mockConfig,
49
44
  }));
50
45
 
51
- mock.module("../config/env.js", () => ({
52
- getPlatformAssistantId: () => mockAssistantId,
53
- }));
54
-
55
- mock.module("../providers/managed-proxy/context.js", () => ({
56
- resolveManagedProxyContext: async () => mockManagedProxyCtx,
46
+ mock.module("../platform/client.js", () => ({
47
+ VellumPlatformClient: {
48
+ create: async () => mockPlatformClient,
49
+ },
57
50
  }));
58
51
 
59
52
  // ---------------------------------------------------------------------------
@@ -68,6 +61,22 @@ import { PlatformOAuthConnection } from "./platform-connection.js";
68
61
  // Helpers
69
62
  // ---------------------------------------------------------------------------
70
63
 
64
+ function makeMockClient() {
65
+ return {
66
+ baseUrl: "https://platform.example.com",
67
+ assistantApiKey: "sk-test-key",
68
+ platformAssistantId: "asst-123",
69
+ fetch: mock(async () => {
70
+ return new Response(
71
+ JSON.stringify({
72
+ results: [{ id: "platform-conn-1", account_label: null }],
73
+ }),
74
+ { status: 200 },
75
+ );
76
+ }),
77
+ };
78
+ }
79
+
71
80
  function setupDefaults(): void {
72
81
  mockProvider = {
73
82
  providerKey: "integration:google",
@@ -100,12 +109,7 @@ function setupDefaults(): void {
100
109
  "google-oauth": { mode: "managed" },
101
110
  },
102
111
  };
103
- mockManagedProxyCtx = {
104
- enabled: true,
105
- platformBaseUrl: "https://platform.example.com",
106
- assistantApiKey: "sk-test-key",
107
- };
108
- mockAssistantId = "asst-123";
112
+ mockPlatformClient = makeMockClient();
109
113
  }
110
114
 
111
115
  // ---------------------------------------------------------------------------
@@ -1,13 +1,15 @@
1
- import { getPlatformAssistantId } from "../config/env.js";
2
1
  import { getConfig } from "../config/loader.js";
3
2
  import { type Services, ServicesSchema } from "../config/schemas/services.js";
4
- import { resolveManagedProxyContext } from "../providers/managed-proxy/context.js";
3
+ import { VellumPlatformClient } from "../platform/client.js";
5
4
  import { getSecureKeyAsync } from "../security/secure-keys.js";
5
+ import { getLogger } from "../util/logger.js";
6
6
  import { BYOOAuthConnection } from "./byo-connection.js";
7
7
  import type { OAuthConnection } from "./connection.js";
8
8
  import { getActiveConnection, getProvider } from "./oauth-store.js";
9
9
  import { PlatformOAuthConnection } from "./platform-connection.js";
10
10
 
11
+ const log = getLogger("connection-resolver");
12
+
11
13
  export interface ResolveOAuthConnectionOptions {
12
14
  /** OAuth app client ID — narrows to a specific app when multiple BYO apps
13
15
  * exist for the same provider. */
@@ -46,16 +48,32 @@ export async function resolveOAuthConnection(
46
48
  if (managedKey && managedKey in ServicesSchema.shape) {
47
49
  const services: Services = getConfig().services;
48
50
  if (services[managedKey as keyof Services].mode === "managed") {
49
- const ctx = await resolveManagedProxyContext();
50
- const assistantId = getPlatformAssistantId();
51
+ const client = await VellumPlatformClient.create();
52
+ if (!client || !client.platformAssistantId) {
53
+ const detail = !client
54
+ ? "missing platform prerequisites"
55
+ : "missing assistant ID";
56
+ throw new Error(
57
+ `Platform-managed connection for "${providerKey}" cannot be created: ${detail}. ` +
58
+ `Log in to the Vellum platform or switch to using your own OAuth app.`,
59
+ );
60
+ }
61
+
62
+ const providerSlug = providerKey.replace(/^integration:/, "");
63
+
64
+ const connectionId = await resolvePlatformConnectionId({
65
+ client,
66
+ provider: providerSlug,
67
+ account,
68
+ });
69
+
51
70
  return new PlatformOAuthConnection({
52
71
  id: providerKey,
53
72
  providerKey,
54
73
  externalId: providerKey,
55
74
  accountInfo: account ?? null,
56
- assistantId,
57
- platformBaseUrl: ctx.platformBaseUrl,
58
- apiKey: ctx.assistantApiKey,
75
+ client,
76
+ connectionId,
59
77
  });
60
78
  }
61
79
  }
@@ -98,3 +116,70 @@ export async function resolveOAuthConnection(
98
116
  accountInfo: conn.accountInfo,
99
117
  });
100
118
  }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Platform connection ID resolution
122
+ // ---------------------------------------------------------------------------
123
+
124
+ interface ResolvePlatformConnectionIdOptions {
125
+ client: VellumPlatformClient;
126
+ provider: string;
127
+ account?: string;
128
+ }
129
+
130
+ /**
131
+ * Fetch the platform-side connection ID for a managed provider by calling
132
+ * the List Connections endpoint.
133
+ */
134
+ async function resolvePlatformConnectionId(
135
+ options: ResolvePlatformConnectionIdOptions,
136
+ ): Promise<string> {
137
+ const { client, provider, account } = options;
138
+
139
+ const params = new URLSearchParams();
140
+ params.set("provider", provider);
141
+ params.set("status", "ACTIVE");
142
+ if (account) {
143
+ params.set("account_identifier", account);
144
+ }
145
+
146
+ const path = `/v1/assistants/${client.platformAssistantId}/oauth/connections/?${params.toString()}`;
147
+ const response = await client.fetch(path);
148
+
149
+ if (!response.ok) {
150
+ log.error(
151
+ { status: response.status, provider },
152
+ "Failed to list platform OAuth connections",
153
+ );
154
+ throw new Error(
155
+ `Failed to resolve platform connection for "${provider}": HTTP ${response.status}`,
156
+ );
157
+ }
158
+
159
+ const body = (await response.json()) as {
160
+ results?: Array<{ id: string; account_label?: string }>;
161
+ };
162
+ const connections = body.results ?? [];
163
+
164
+ if (connections.length === 0) {
165
+ throw new Error(
166
+ `No active platform OAuth connection found for provider "${provider}"` +
167
+ (account ? ` with account "${account}"` : "") +
168
+ ". Connect the service on the Vellum platform first.",
169
+ );
170
+ }
171
+
172
+ if (connections.length > 1 && !account) {
173
+ log.warn(
174
+ {
175
+ provider,
176
+ count: connections.length,
177
+ selectedId: connections[0].id,
178
+ },
179
+ "Multiple active platform connections found; using the most recently created. " +
180
+ "Pass an account option to select a specific connection.",
181
+ );
182
+ }
183
+
184
+ return connections[0].id;
185
+ }
@@ -1,5 +1,6 @@
1
- import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import { describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import type { VellumPlatformClient } from "../platform/client.js";
3
4
  import { BackendError, VellumError } from "../util/errors.js";
4
5
  import {
5
6
  CredentialRequiredError,
@@ -7,40 +8,53 @@ import {
7
8
  ProviderUnreachableError,
8
9
  } from "./platform-connection.js";
9
10
 
11
+ function makeMockClient(
12
+ fetchImpl?: typeof globalThis.fetch,
13
+ ): VellumPlatformClient {
14
+ const mockFetchFn =
15
+ fetchImpl ??
16
+ (mock(async () => {
17
+ return new Response(
18
+ JSON.stringify({ status: 200, headers: {}, body: null }),
19
+ { status: 200 },
20
+ );
21
+ }) as unknown as typeof globalThis.fetch);
22
+
23
+ return {
24
+ baseUrl: "https://platform.example.com",
25
+ assistantApiKey: "test-api-key",
26
+ platformAssistantId: "asst-abc",
27
+ fetch: mock(async (path: string, init?: RequestInit) => {
28
+ const url = `https://platform.example.com${path}`;
29
+ const headers = new Headers(init?.headers);
30
+ headers.set("Authorization", "Api-Key test-api-key");
31
+ return mockFetchFn(url, { ...init, headers });
32
+ }),
33
+ } as unknown as VellumPlatformClient;
34
+ }
35
+
10
36
  const DEFAULT_OPTIONS = {
11
37
  id: "conn-1",
12
38
  providerKey: "integration:google",
13
39
  externalId: "ext-123",
14
40
  accountInfo: "user@example.com",
15
- assistantId: "asst-abc",
16
- platformBaseUrl: "https://platform.example.com",
17
- apiKey: "test-api-key",
41
+ client: makeMockClient(),
42
+ connectionId: "platform-conn-123",
18
43
  };
19
44
 
20
45
  describe("PlatformOAuthConnection", () => {
21
- let originalFetch: typeof globalThis.fetch;
22
-
23
- beforeEach(() => {
24
- originalFetch = globalThis.fetch;
25
- });
26
-
27
- afterEach(() => {
28
- globalThis.fetch = originalFetch;
29
- });
30
-
31
46
  test("successful proxied request", async () => {
32
47
  const upstreamBody = { messages: [{ id: "msg-1", snippet: "Hello" }] };
33
48
 
34
- globalThis.fetch = mock(
35
- async (url: string | URL | Request, init?: RequestInit) => {
36
- expect(url).toBe(
37
- "https://platform.example.com/v1/assistants/asst-abc/external-provider-proxy/google/",
49
+ const client = makeMockClient(
50
+ mock(async (url: string | URL | Request, init?: RequestInit) => {
51
+ expect(String(url)).toBe(
52
+ "https://platform.example.com/v1/assistants/asst-abc/external-provider-proxy/platform-conn-123/",
38
53
  );
39
54
  expect(init?.method).toBe("POST");
40
- expect(init?.headers).toEqual({
41
- Authorization: "Api-Key test-api-key",
42
- "Content-Type": "application/json",
43
- });
55
+ const headers = new Headers(init?.headers);
56
+ expect(headers.get("Authorization")).toBe("Api-Key test-api-key");
57
+ expect(headers.get("Content-Type")).toBe("application/json");
44
58
 
45
59
  const parsed = JSON.parse(init?.body as string);
46
60
  expect(parsed).toEqual({
@@ -61,10 +75,13 @@ describe("PlatformOAuthConnection", () => {
61
75
  }),
62
76
  { status: 200 },
63
77
  );
64
- },
65
- ) as unknown as typeof globalThis.fetch;
78
+ }) as unknown as typeof globalThis.fetch,
79
+ );
66
80
 
67
- const conn = new PlatformOAuthConnection(DEFAULT_OPTIONS);
81
+ const conn = new PlatformOAuthConnection({
82
+ ...DEFAULT_OPTIONS,
83
+ client,
84
+ });
68
85
  const result = await conn.request({
69
86
  method: "GET",
70
87
  path: "/gmail/v1/users/me/messages",
@@ -77,8 +94,8 @@ describe("PlatformOAuthConnection", () => {
77
94
  });
78
95
 
79
96
  test("forwards baseUrl when provided", async () => {
80
- globalThis.fetch = mock(
81
- async (_url: string | URL | Request, init?: RequestInit) => {
97
+ const client = makeMockClient(
98
+ mock(async (_url: string | URL | Request, init?: RequestInit) => {
82
99
  const parsed = JSON.parse(init?.body as string);
83
100
  expect(parsed.request.baseUrl).toBe(
84
101
  "https://www.googleapis.com/calendar/v3",
@@ -88,10 +105,10 @@ describe("PlatformOAuthConnection", () => {
88
105
  JSON.stringify({ status: 200, headers: {}, body: {} }),
89
106
  { status: 200 },
90
107
  );
91
- },
92
- ) as unknown as typeof globalThis.fetch;
108
+ }) as unknown as typeof globalThis.fetch,
109
+ );
93
110
 
94
- const conn = new PlatformOAuthConnection(DEFAULT_OPTIONS);
111
+ const conn = new PlatformOAuthConnection({ ...DEFAULT_OPTIONS, client });
95
112
  await conn.request({
96
113
  method: "GET",
97
114
  path: "/calendars/primary/events",
@@ -100,8 +117,8 @@ describe("PlatformOAuthConnection", () => {
100
117
  });
101
118
 
102
119
  test("omits baseUrl from envelope when not provided", async () => {
103
- globalThis.fetch = mock(
104
- async (_url: string | URL | Request, init?: RequestInit) => {
120
+ const client = makeMockClient(
121
+ mock(async (_url: string | URL | Request, init?: RequestInit) => {
105
122
  const parsed = JSON.parse(init?.body as string);
106
123
  expect("baseUrl" in parsed.request).toBe(false);
107
124
 
@@ -109,10 +126,10 @@ describe("PlatformOAuthConnection", () => {
109
126
  JSON.stringify({ status: 200, headers: {}, body: null }),
110
127
  { status: 200 },
111
128
  );
112
- },
113
- ) as unknown as typeof globalThis.fetch;
129
+ }) as unknown as typeof globalThis.fetch,
130
+ );
114
131
 
115
- const conn = new PlatformOAuthConnection(DEFAULT_OPTIONS);
132
+ const conn = new PlatformOAuthConnection({ ...DEFAULT_OPTIONS, client });
116
133
  await conn.request({ method: "GET", path: "/some/path" });
117
134
  });
118
135
 
@@ -127,22 +144,26 @@ describe("PlatformOAuthConnection", () => {
127
144
  });
128
145
 
129
146
  test("424 response throws CredentialRequiredError", async () => {
130
- globalThis.fetch = mock(async () => {
131
- return new Response("", { status: 424 });
132
- }) as unknown as typeof globalThis.fetch;
147
+ const client = makeMockClient(
148
+ mock(
149
+ async () => new Response("", { status: 424 }),
150
+ ) as unknown as typeof globalThis.fetch,
151
+ );
133
152
 
134
- const conn = new PlatformOAuthConnection(DEFAULT_OPTIONS);
153
+ const conn = new PlatformOAuthConnection({ ...DEFAULT_OPTIONS, client });
135
154
  await expect(
136
155
  conn.request({ method: "GET", path: "/test" }),
137
156
  ).rejects.toThrow(CredentialRequiredError);
138
157
  });
139
158
 
140
159
  test("502 response throws ProviderUnreachableError", async () => {
141
- globalThis.fetch = mock(async () => {
142
- return new Response("", { status: 502 });
143
- }) as unknown as typeof globalThis.fetch;
160
+ const client = makeMockClient(
161
+ mock(
162
+ async () => new Response("", { status: 502 }),
163
+ ) as unknown as typeof globalThis.fetch,
164
+ );
144
165
 
145
- const conn = new PlatformOAuthConnection(DEFAULT_OPTIONS);
166
+ const conn = new PlatformOAuthConnection({ ...DEFAULT_OPTIONS, client });
146
167
  await expect(
147
168
  conn.request({ method: "GET", path: "/test" }),
148
169
  ).rejects.toThrow(ProviderUnreachableError);
@@ -155,36 +176,24 @@ describe("PlatformOAuthConnection", () => {
155
176
  );
156
177
  });
157
178
 
158
- test("strips trailing slash from platformBaseUrl to avoid double slashes", async () => {
159
- globalThis.fetch = mock(async (url: string | URL | Request) => {
160
- expect(String(url)).toBe(
161
- "https://platform.example.com/v1/assistants/asst-abc/external-provider-proxy/google/",
162
- );
163
- return new Response(
164
- JSON.stringify({ status: 200, headers: {}, body: null }),
165
- { status: 200 },
166
- );
167
- }) as unknown as typeof globalThis.fetch;
168
-
169
- const conn = new PlatformOAuthConnection({
170
- ...DEFAULT_OPTIONS,
171
- platformBaseUrl: "https://platform.example.com/",
172
- });
173
- await conn.request({ method: "GET", path: "/test" });
174
- });
175
-
176
- test("strips integration: prefix from providerKey for slug", async () => {
177
- globalThis.fetch = mock(async (url: string | URL | Request) => {
178
- expect(String(url)).toContain("/external-provider-proxy/slack/");
179
- return new Response(
180
- JSON.stringify({ status: 200, headers: {}, body: null }),
181
- { status: 200 },
182
- );
183
- }) as unknown as typeof globalThis.fetch;
179
+ test("uses connectionId in proxy URL regardless of providerKey format", async () => {
180
+ const client = makeMockClient(
181
+ mock(async (url: string | URL | Request) => {
182
+ expect(String(url)).toContain(
183
+ "/external-provider-proxy/slack-conn-456/",
184
+ );
185
+ return new Response(
186
+ JSON.stringify({ status: 200, headers: {}, body: null }),
187
+ { status: 200 },
188
+ );
189
+ }) as unknown as typeof globalThis.fetch,
190
+ );
184
191
 
185
192
  const conn = new PlatformOAuthConnection({
186
193
  ...DEFAULT_OPTIONS,
194
+ client,
187
195
  providerKey: "integration:slack",
196
+ connectionId: "slack-conn-456",
188
197
  });
189
198
  await conn.request({ method: "GET", path: "/test" });
190
199
  });