@vellumai/assistant 0.5.3 → 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 (111) hide show
  1. package/Dockerfile +18 -27
  2. package/docs/architecture/memory.md +105 -0
  3. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
  4. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/archive-recall.test.ts +560 -0
  7. package/src/__tests__/conversation-clear-safety.test.ts +259 -0
  8. package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
  9. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  10. package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
  11. package/src/__tests__/memory-reducer-job.test.ts +538 -0
  12. package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
  13. package/src/__tests__/memory-reducer-types.test.ts +12 -4
  14. package/src/__tests__/memory-reducer.test.ts +7 -1
  15. package/src/__tests__/memory-regressions.test.ts +24 -4
  16. package/src/__tests__/memory-simplified-config.test.ts +4 -4
  17. package/src/__tests__/openai-whisper.test.ts +93 -0
  18. package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
  19. package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
  20. package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
  21. package/src/__tests__/volume-security-guard.test.ts +155 -0
  22. package/src/cli/commands/conversations.ts +18 -0
  23. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
  24. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  25. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
  26. package/src/config/env-registry.ts +9 -0
  27. package/src/config/feature-flag-registry.json +8 -0
  28. package/src/config/loader.ts +0 -1
  29. package/src/config/schemas/memory-simplified.ts +1 -1
  30. package/src/credential-execution/managed-catalog.ts +5 -15
  31. package/src/daemon/config-watcher.ts +4 -1
  32. package/src/daemon/conversation-memory.ts +117 -0
  33. package/src/daemon/conversation-runtime-assembly.ts +1 -0
  34. package/src/daemon/daemon-control.ts +7 -0
  35. package/src/daemon/handlers/conversations.ts +11 -0
  36. package/src/daemon/lifecycle.ts +51 -2
  37. package/src/daemon/providers-setup.ts +2 -1
  38. package/src/hooks/manager.ts +7 -0
  39. package/src/instrument.ts +33 -1
  40. package/src/memory/archive-recall.ts +516 -0
  41. package/src/memory/brief-time.ts +5 -4
  42. package/src/memory/conversation-crud.ts +210 -0
  43. package/src/memory/conversation-key-store.ts +33 -4
  44. package/src/memory/db-init.ts +4 -0
  45. package/src/memory/embedding-local.ts +11 -5
  46. package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
  47. package/src/memory/job-handlers/conversation-starters.ts +24 -30
  48. package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
  49. package/src/memory/jobs-store.ts +2 -0
  50. package/src/memory/jobs-worker.ts +8 -0
  51. package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
  52. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
  53. package/src/memory/migrations/141-rename-verification-table.ts +8 -0
  54. package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
  55. package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
  56. package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
  57. package/src/memory/migrations/index.ts +1 -0
  58. package/src/memory/reducer-scheduler.ts +242 -0
  59. package/src/memory/reducer-types.ts +9 -2
  60. package/src/memory/reducer.ts +25 -11
  61. package/src/memory/schema/infrastructure.ts +1 -0
  62. package/src/messaging/provider.ts +9 -0
  63. package/src/messaging/providers/slack/adapter.ts +29 -2
  64. package/src/oauth/connection-resolver.test.ts +22 -18
  65. package/src/oauth/connection-resolver.ts +92 -7
  66. package/src/oauth/platform-connection.test.ts +78 -69
  67. package/src/oauth/platform-connection.ts +12 -19
  68. package/src/permissions/trust-client.ts +343 -0
  69. package/src/permissions/trust-store-interface.ts +105 -0
  70. package/src/permissions/trust-store.ts +523 -36
  71. package/src/platform/client.test.ts +148 -0
  72. package/src/platform/client.ts +71 -0
  73. package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
  74. package/src/providers/speech-to-text/openai-whisper.ts +68 -0
  75. package/src/providers/speech-to-text/resolve.ts +9 -0
  76. package/src/providers/speech-to-text/types.ts +17 -0
  77. package/src/runtime/auth/route-policy.ts +10 -1
  78. package/src/runtime/http-server.ts +2 -2
  79. package/src/runtime/routes/conversation-management-routes.ts +88 -2
  80. package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
  81. package/src/runtime/routes/inbound-message-handler.ts +27 -3
  82. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
  83. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
  84. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
  85. package/src/runtime/routes/log-export-routes.ts +1 -0
  86. package/src/runtime/routes/secret-routes.ts +5 -1
  87. package/src/schedule/schedule-store.ts +7 -0
  88. package/src/schedule/scheduler.ts +6 -2
  89. package/src/security/ces-credential-client.ts +173 -0
  90. package/src/security/secure-keys.ts +65 -22
  91. package/src/signals/bash.ts +3 -0
  92. package/src/signals/cancel.ts +3 -0
  93. package/src/signals/confirm.ts +3 -0
  94. package/src/signals/conversation-undo.ts +3 -0
  95. package/src/signals/event-stream.ts +7 -0
  96. package/src/signals/shotgun.ts +3 -0
  97. package/src/signals/trust-rule.ts +3 -0
  98. package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
  99. package/src/telemetry/usage-telemetry-reporter.ts +22 -20
  100. package/src/tools/filesystem/edit.ts +6 -1
  101. package/src/tools/filesystem/read.ts +6 -1
  102. package/src/tools/filesystem/write.ts +6 -1
  103. package/src/tools/memory/handlers.ts +129 -1
  104. package/src/tools/schedule/create.ts +3 -0
  105. package/src/tools/schedule/list.ts +5 -1
  106. package/src/tools/schedule/update.ts +6 -0
  107. package/src/util/device-id.ts +70 -7
  108. package/src/util/logger.ts +35 -9
  109. package/src/util/platform.ts +29 -5
  110. package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
  111. package/src/workspace/migrations/registry.ts +2 -0
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Reducer scheduler — synchronous pre-switch/create reduction of the most
3
+ * recently updated dirty conversation.
4
+ *
5
+ * When the user switches conversations or starts a new one, we want the
6
+ * *previous* conversation's memory to be reduced before the next memory
7
+ * read. This module exposes {@link reduceBeforeSwitch} which:
8
+ *
9
+ * 1. Finds the single most recently updated dirty conversation (excluding
10
+ * the target conversation).
11
+ * 2. Runs the same reduction pipeline the background job uses (load
12
+ * unreduced messages, call {@link runReducer}, apply via
13
+ * {@link applyReducerResult}).
14
+ * 3. Awaits the result so the caller can proceed knowing memory is fresh.
15
+ *
16
+ * If no eligible dirty conversation exists, the function returns immediately.
17
+ */
18
+
19
+ import { and, asc, desc, eq, gte, isNotNull, ne } from "drizzle-orm";
20
+
21
+ import { getLogger } from "../util/logger.js";
22
+ import { type ConversationRow, getConversation } from "./conversation-crud.js";
23
+ import { getDb } from "./db.js";
24
+ import { type ReducerPromptInput, runReducer } from "./reducer.js";
25
+ import {
26
+ applyReducerResult,
27
+ getActiveOpenLoops,
28
+ getActiveTimeContexts,
29
+ } from "./reducer-store.js";
30
+ import { EMPTY_REDUCER_RESULT } from "./reducer-types.js";
31
+ import { conversations, messages } from "./schema.js";
32
+
33
+ const log = getLogger("reducer-scheduler");
34
+
35
+ // ── Internal helpers ────────────────────────────────────────────────
36
+
37
+ interface MessageRow {
38
+ id: string;
39
+ role: string;
40
+ content: string;
41
+ createdAt: number;
42
+ }
43
+
44
+ /**
45
+ * Find the single most recently updated dirty conversation, excluding
46
+ * the target conversation. Returns the conversation ID or null if none.
47
+ */
48
+ export function findMostRecentDirtyConversation(
49
+ excludeConversationId: string,
50
+ ): string | null {
51
+ const db = getDb();
52
+ const row = db
53
+ .select({ id: conversations.id })
54
+ .from(conversations)
55
+ .where(
56
+ and(
57
+ isNotNull(conversations.memoryDirtyTailSinceMessageId),
58
+ ne(conversations.id, excludeConversationId),
59
+ ),
60
+ )
61
+ .orderBy(desc(conversations.updatedAt))
62
+ .limit(1)
63
+ .get();
64
+
65
+ return row?.id ?? null;
66
+ }
67
+
68
+ /**
69
+ * Load messages from `dirtyTailMessageId` onward (inclusive), ordered by
70
+ * createdAt ascending.
71
+ */
72
+ function loadUnreducedMessages(
73
+ conversationId: string,
74
+ dirtyTailMessageId: string,
75
+ ): MessageRow[] {
76
+ const db = getDb();
77
+
78
+ const tailMessage = db
79
+ .select({ createdAt: messages.createdAt })
80
+ .from(messages)
81
+ .where(eq(messages.id, dirtyTailMessageId))
82
+ .get();
83
+
84
+ if (!tailMessage) {
85
+ return [];
86
+ }
87
+
88
+ return db
89
+ .select({
90
+ id: messages.id,
91
+ role: messages.role,
92
+ content: messages.content,
93
+ createdAt: messages.createdAt,
94
+ })
95
+ .from(messages)
96
+ .where(
97
+ and(
98
+ eq(messages.conversationId, conversationId),
99
+ gte(messages.createdAt, tailMessage.createdAt),
100
+ ),
101
+ )
102
+ .orderBy(asc(messages.createdAt))
103
+ .all();
104
+ }
105
+
106
+ /**
107
+ * Build the `newMessages` array for the reducer input, optionally
108
+ * prepending the conversation's contextSummary as a synthetic system message.
109
+ */
110
+ function buildNewMessages(
111
+ conversation: ConversationRow,
112
+ unreducedMessages: MessageRow[],
113
+ ): Array<{ role: string; content: string }> {
114
+ const result: Array<{ role: string; content: string }> = [];
115
+
116
+ if (conversation.contextSummary) {
117
+ result.push({
118
+ role: "system",
119
+ content: `[Prior context summary] ${conversation.contextSummary}`,
120
+ });
121
+ }
122
+
123
+ for (const msg of unreducedMessages) {
124
+ result.push({ role: msg.role, content: msg.content });
125
+ }
126
+
127
+ return result;
128
+ }
129
+
130
+ // ── Public API ──────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * Reduce the most recently updated dirty conversation (excluding
134
+ * `targetConversationId`) before a conversation switch or create.
135
+ *
136
+ * This runs the full reduction pipeline synchronously (awaiting the
137
+ * provider call) so the caller can proceed knowing memory is fresh.
138
+ *
139
+ * Returns the conversation ID that was reduced, or null if none were eligible.
140
+ */
141
+ export async function reduceBeforeSwitch(
142
+ targetConversationId: string,
143
+ ): Promise<string | null> {
144
+ const dirtyConversationId =
145
+ findMostRecentDirtyConversation(targetConversationId);
146
+
147
+ if (!dirtyConversationId) {
148
+ return null;
149
+ }
150
+
151
+ const conversation = getConversation(dirtyConversationId);
152
+ if (!conversation) {
153
+ return null;
154
+ }
155
+
156
+ const dirtyTailMessageId = conversation.memoryDirtyTailSinceMessageId;
157
+ if (!dirtyTailMessageId) {
158
+ return null;
159
+ }
160
+
161
+ // ── Load unreduced messages ──────────────────────────────────
162
+ const unreducedMessages = loadUnreducedMessages(
163
+ dirtyConversationId,
164
+ dirtyTailMessageId,
165
+ );
166
+
167
+ if (unreducedMessages.length === 0) {
168
+ log.debug(
169
+ { conversationId: dirtyConversationId, dirtyTailMessageId },
170
+ "No messages found from dirty tail — nothing to reduce on switch",
171
+ );
172
+ return null;
173
+ }
174
+
175
+ // ── Load active brief-state context ──────────────────────────
176
+ const scopeId = conversation.memoryScopeId;
177
+ const now = Date.now();
178
+
179
+ const existingTimeContexts = getActiveTimeContexts(scopeId, now);
180
+ const existingOpenLoops = getActiveOpenLoops(scopeId);
181
+
182
+ // ── Build reducer input ──────────────────────────────────────
183
+ const newMessages = buildNewMessages(conversation, unreducedMessages);
184
+
185
+ const reducerInput: ReducerPromptInput = {
186
+ conversationId: dirtyConversationId,
187
+ newMessages,
188
+ existingTimeContexts: existingTimeContexts.map((tc) => ({
189
+ id: tc.id,
190
+ summary: tc.summary,
191
+ })),
192
+ existingOpenLoops: existingOpenLoops.map((ol) => ({
193
+ id: ol.id,
194
+ summary: ol.summary,
195
+ status: ol.status,
196
+ })),
197
+ nowMs: now,
198
+ scopeId,
199
+ };
200
+
201
+ // ── Run the reducer ──────────────────────────────────────────
202
+ try {
203
+ const result = await runReducer(reducerInput);
204
+
205
+ if (result === EMPTY_REDUCER_RESULT) {
206
+ log.debug(
207
+ { conversationId: dirtyConversationId },
208
+ "Reducer returned empty result on switch — not advancing checkpoint",
209
+ );
210
+ return null;
211
+ }
212
+
213
+ // ── Apply result transactionally ───────────────────────────
214
+ const lastMessage = unreducedMessages[unreducedMessages.length - 1];
215
+ applyReducerResult({
216
+ result,
217
+ conversationId: dirtyConversationId,
218
+ scopeId,
219
+ reducedThroughMessageId: lastMessage.id,
220
+ now,
221
+ });
222
+
223
+ log.info(
224
+ {
225
+ conversationId: dirtyConversationId,
226
+ reducedThroughMessageId: lastMessage.id,
227
+ messageCount: unreducedMessages.length,
228
+ timeContextOps: result.timeContexts.length,
229
+ openLoopOps: result.openLoops.length,
230
+ },
231
+ "Pre-switch memory reduction completed",
232
+ );
233
+
234
+ return dirtyConversationId;
235
+ } catch (err) {
236
+ log.warn(
237
+ { err, conversationId: dirtyConversationId },
238
+ "Pre-switch memory reduction failed — continuing with switch",
239
+ );
240
+ return null;
241
+ }
242
+ }
@@ -86,8 +86,15 @@ export interface ReducerResult {
86
86
  }
87
87
 
88
88
  /**
89
- * An empty result used as fallback when the reducer output is invalid or
90
- * unparseable. Guarantees no side-effects on the DB.
89
+ * Sentinel empty result returned when the reducer output is **unparseable**
90
+ * (not valid JSON, not a JSON object, provider failure, etc.).
91
+ *
92
+ * Callers use identity comparison (`=== EMPTY_REDUCER_RESULT`) to detect
93
+ * true parse failures and skip checkpoint advancement so the job can retry.
94
+ *
95
+ * A valid-but-empty model response (e.g. `{}`) returns a normal
96
+ * `ReducerResult` with all empty arrays — NOT this sentinel — so the
97
+ * checkpoint advances and the dirty tail is cleared.
91
98
  */
92
99
  export const EMPTY_REDUCER_RESULT: Readonly<ReducerResult> = Object.freeze({
93
100
  timeContexts: Object.freeze([]) as unknown as TimeContextOp[],
@@ -5,7 +5,7 @@
5
5
  * 1. ReducerPromptInput — structured input for the provider call
6
6
  * 2. runReducer — send the transcript span to the LLM and return a typed result
7
7
  * 3. parseReducerOutput — raw string -> validated ReducerResult
8
- * 4. Fallback to EMPTY_REDUCER_RESULT on any invalid output
8
+ * 4. Fallback to EMPTY_REDUCER_RESULT on unparseable output (parse failures only)
9
9
  *
10
10
  * The reducer is intentionally side-effect-free: it never writes to the
11
11
  * database. Callers are responsible for applying the returned ReducerResult.
@@ -373,13 +373,19 @@ function validateArchiveEpisode(raw: unknown): ArchiveEpisodeCandidate | null {
373
373
  /**
374
374
  * Parse raw model output into a validated ReducerResult.
375
375
  *
376
- * On any structural error (non-JSON, missing top-level keys, wrong types)
377
- * the function returns EMPTY_REDUCER_RESULT rather than throwing. Individual
378
- * invalid operations within an otherwise valid structure are silently dropped
379
- * to preserve the rest of the result.
376
+ * On any structural error (non-JSON, not a JSON object) the function returns
377
+ * {@link EMPTY_REDUCER_RESULT} rather than throwing — callers use identity
378
+ * comparison (`=== EMPTY_REDUCER_RESULT`) to detect true parse failures and
379
+ * skip checkpoint advancement.
380
380
  *
381
- * However, if **all four** top-level arrays are absent or not arrays, the
382
- * entire output is treated as invalid and returns the empty result.
381
+ * A valid JSON object with no recognized top-level arrays (e.g. `{}`) is
382
+ * treated as a **valid-but-empty** response the model simply had nothing
383
+ * to extract. In this case a normal `ReducerResult` with all empty arrays
384
+ * is returned so that callers advance the checkpoint and clear the dirty
385
+ * tail, avoiding an infinite retry loop.
386
+ *
387
+ * Individual invalid operations within an otherwise valid structure are
388
+ * silently dropped to preserve the rest of the result.
383
389
  */
384
390
  export function parseReducerOutput(raw: string): ReducerResult {
385
391
  let parsed: unknown;
@@ -399,22 +405,30 @@ export function parseReducerOutput(raw: string): ReducerResult {
399
405
 
400
406
  const obj = parsed as Record<string, unknown>;
401
407
 
402
- // Check that at least one top-level array key exists
408
+ // Check which top-level array keys are present
403
409
  const hasTimeContexts = Array.isArray(obj.timeContexts);
404
410
  const hasOpenLoops = Array.isArray(obj.openLoops);
405
411
  const hasArchiveObservations = Array.isArray(obj.archiveObservations);
406
412
  const hasArchiveEpisodes = Array.isArray(obj.archiveEpisodes);
407
413
 
414
+ // A valid JSON object with no recognized arrays (e.g. `{}`) means the
415
+ // model had nothing to extract — return a normal (non-sentinel) empty
416
+ // result so the checkpoint advances.
408
417
  if (
409
418
  !hasTimeContexts &&
410
419
  !hasOpenLoops &&
411
420
  !hasArchiveObservations &&
412
421
  !hasArchiveEpisodes
413
422
  ) {
414
- log.warn(
415
- "reducer output has no recognized top-level arraysfalling back to empty result",
423
+ log.debug(
424
+ "reducer output is valid JSON with no extractions advancing with empty result",
416
425
  );
417
- return EMPTY_REDUCER_RESULT;
426
+ return {
427
+ timeContexts: [],
428
+ openLoops: [],
429
+ archiveObservations: [],
430
+ archiveEpisodes: [],
431
+ };
418
432
  }
419
433
 
420
434
  const timeContexts: TimeContextOp[] = [];
@@ -24,6 +24,7 @@ export const cronJobs = sqliteTable("cron_jobs", {
24
24
  routingIntent: text("routing_intent").notNull().default("all_channels"), // 'single_channel' | 'multi_channel' | 'all_channels'
25
25
  routingHintsJson: text("routing_hints_json").notNull().default("{}"),
26
26
  status: text("status").notNull().default("active"), // 'active' | 'firing' | 'fired' | 'cancelled'
27
+ quiet: integer("quiet", { mode: "boolean" }).notNull().default(false), // suppress completion notifications
27
28
  createdAt: integer("created_at").notNull(),
28
29
  updatedAt: integer("updated_at").notNull(),
29
30
  });
@@ -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
+ }