@vellumai/assistant 0.11.0-staging.2 → 0.11.0

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.
@@ -123,6 +123,13 @@ build can never drift. The same predicate gates procedural-memory-as-skills,
123
123
  so both v3-tier features honor the Memory opt-out identically. `GET /v1/memory-graph-node` serves node detail,
124
124
  including `buffer:` ids for pending entries.
125
125
 
126
+ `GET /v1/memory/stats` also reports `tier` (`memoryTier()`: `off` / `v1` /
127
+ `v2` / `v3`), which is what lets the web Memory tab explain an unavailable
128
+ graph instead of stating a bare "not available": `off` is the user's own
129
+ Memory opt-out and points at Settings, while `v1`/`v2` point at the v3
130
+ migration. `graph_supported` is exactly `tier === "v3"`, so the capability
131
+ bit and its explanation are derived from one gate.
132
+
126
133
  ## Capture beyond `remember`
127
134
 
128
135
  - **Retrospective** (`memory-retrospective-*.ts`): periodic per-conversation
package/openapi.yaml CHANGED
@@ -17747,7 +17747,9 @@ paths:
17747
17747
  "Return a cheap count of concept pages from the cached memory page index, for glanceable surfaces like the
17748
17748
  identity Memory card. Counts concept pages only and never builds the memory-concept graph. Also reports
17749
17749
  graph_supported: whether the memory-concept graph is available for this assistant (memory enabled and v3 live),
17750
- so callers can gate the graph entry point without building the graph."
17750
+ so callers can gate the graph entry point without building the graph, plus tier: the coarse memory tier
17751
+ explaining why the graph is unavailable (off = the user's Memory opt-out, v1/v2 = a legacy engine that has not
17752
+ migrated to v3)."
17751
17753
  tags:
17752
17754
  - memory
17753
17755
  responses:
@@ -17764,9 +17766,18 @@ paths:
17764
17766
  graph_supported:
17765
17767
  type: boolean
17766
17768
  description: Whether the memory-concept graph is available (memory enabled and v3 live)
17769
+ tier:
17770
+ type: string
17771
+ enum:
17772
+ - off
17773
+ - v1
17774
+ - v2
17775
+ - v3
17776
+ description: Coarse memory tier for this assistant; graph_supported is exactly tier === 'v3'
17767
17777
  required:
17768
17778
  - concepts
17769
17779
  - graph_supported
17780
+ - tier
17770
17781
  additionalProperties: false
17771
17782
  /v1/memory/v2/backfill:
17772
17783
  post:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.0-staging.2",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -6,6 +6,8 @@ import { setConfig } from "./helpers/set-config.js";
6
6
  let mockSecureKeys: Record<string, string>;
7
7
  let mockHasTwilioCredentials: boolean;
8
8
  let mockGetIsPlatform: boolean;
9
+ /** Platform credentials present: base URL + assistant ID + assistant API key. */
10
+ let mockPlatformConnected: boolean;
9
11
 
10
12
  mock.module("../calls/twilio-rest.js", () => ({
11
13
  getPhoneNumberSid: async () => null,
@@ -24,7 +26,22 @@ mock.module("../config/env-registry.js", () => ({
24
26
  getIsPlatform: () => mockGetIsPlatform,
25
27
  }));
26
28
 
27
- mock.module("../inbound/platform-callback-registration.js", () => ({}));
29
+ // Spread the real module: replacing it wholesale drops the exports peer test
30
+ // files import from it, which breaks their named-import validation whenever
31
+ // this mock wins evaluation in a combined run.
32
+ const actualRegistration =
33
+ await import("../inbound/platform-callback-registration.js");
34
+ mock.module("../inbound/platform-callback-registration.js", () => ({
35
+ ...actualRegistration,
36
+ resolvePlatformCallbackRegistrationContext: async () => ({
37
+ isPlatform: mockGetIsPlatform,
38
+ platformBaseUrl: "https://api.vellum.ai",
39
+ assistantId: mockPlatformConnected ? "assistant-123" : "",
40
+ hasAssistantApiKey: mockPlatformConnected,
41
+ authHeader: mockPlatformConnected ? "Api-Key secret" : null,
42
+ enabled: mockPlatformConnected,
43
+ }),
44
+ }));
28
45
 
29
46
  mock.module("../security/secure-keys.js", () => ({
30
47
  getSecureKeyAsync: async (key: string) => mockSecureKeys[key] ?? null,
@@ -85,6 +102,7 @@ describe("ChannelReadinessService", () => {
85
102
  mockSecureKeys = {};
86
103
  mockHasTwilioCredentials = false;
87
104
  mockGetIsPlatform = false;
105
+ mockPlatformConnected = false;
88
106
  });
89
107
 
90
108
  test("local checks run on every call (no caching of local results)", async () => {
@@ -278,6 +296,94 @@ describe("ChannelReadinessService", () => {
278
296
  });
279
297
  });
280
298
 
299
+ test("telegram readiness accepts a platform-connected assistant with no ingress", async () => {
300
+ // LUM-2882: `webhooks register telegram` resolves a platform callback URL
301
+ // in this configuration, so reporting missing ingress here would hide a
302
+ // registration that is really in place (or really broken).
303
+ mockPlatformConnected = true;
304
+ mockSecureKeys[credentialKey("telegram", "bot_token")] = "123:abc";
305
+ mockSecureKeys[credentialKey("telegram", "webhook_secret")] = "secret";
306
+
307
+ const readiness = createReadinessService();
308
+ const [snapshot] = await readiness.getReadiness("telegram");
309
+
310
+ expect(snapshot.ready).toBe(true);
311
+ expect(snapshot.localChecks).toContainEqual({
312
+ name: "ingress",
313
+ passed: true,
314
+ message: "Managed platform callback routing is configured",
315
+ });
316
+ });
317
+
318
+ test("phone readiness accepts a platform-connected assistant with no ingress", async () => {
319
+ mockPlatformConnected = true;
320
+ mockHasTwilioCredentials = true;
321
+ setConfig("twilio", { phoneNumber: "+15550123" });
322
+
323
+ const readiness = createReadinessService();
324
+ const [snapshot] = await readiness.getReadiness("phone");
325
+
326
+ expect(snapshot.ready).toBe(true);
327
+ expect(snapshot.localChecks).toContainEqual({
328
+ name: "ingress",
329
+ passed: true,
330
+ message: "Managed platform callback routing is configured",
331
+ });
332
+ });
333
+
334
+ test("configured ingress beats the platform-connected fallback", async () => {
335
+ // Any logged-in local assistant holds platform credentials for the LLM
336
+ // proxy. Reporting managed routing here would mislabel a webhook that
337
+ // `webhooks register` resolves to the self-hosted URL.
338
+ mockPlatformConnected = true;
339
+ mockSecureKeys[credentialKey("telegram", "bot_token")] = "123:abc";
340
+ mockSecureKeys[credentialKey("telegram", "webhook_secret")] = "secret";
341
+ setConfig("ingress", { publicBaseUrl: "https://tunnel.example.com" });
342
+
343
+ const readiness = createReadinessService();
344
+ const [snapshot] = await readiness.getReadiness("telegram");
345
+
346
+ expect(snapshot.ready).toBe(true);
347
+ expect(snapshot.localChecks).toContainEqual({
348
+ name: "ingress",
349
+ passed: true,
350
+ message: "Public ingress URL is configured",
351
+ });
352
+ });
353
+
354
+ test("explicit ingress opt-out blocks the platform-connected fallback", async () => {
355
+ // Switching ingress off is a decision not to accept inbound webhooks, not
356
+ // an invitation to route them through the platform instead.
357
+ mockPlatformConnected = true;
358
+ mockSecureKeys[credentialKey("telegram", "bot_token")] = "123:abc";
359
+ mockSecureKeys[credentialKey("telegram", "webhook_secret")] = "secret";
360
+ setConfig("ingress", { enabled: false });
361
+
362
+ const readiness = createReadinessService();
363
+ const [snapshot] = await readiness.getReadiness("telegram");
364
+
365
+ expect(snapshot.ready).toBe(false);
366
+ expect(snapshot.reasons).toContainEqual({
367
+ code: "ingress",
368
+ text: "No public ingress URL or managed callback route is configured",
369
+ });
370
+ });
371
+
372
+ test("email readiness ignores platform connectivity", async () => {
373
+ // Email passes `allowManagedCallbacks: false`, so the managed tiers are
374
+ // not offered to it and only a real ingress URL counts.
375
+ mockPlatformConnected = true;
376
+
377
+ const readiness = createReadinessService();
378
+ const [snapshot] = await readiness.getReadiness("email");
379
+
380
+ expect(snapshot.localChecks).toContainEqual({
381
+ name: "ingress",
382
+ passed: false,
383
+ message: "Public ingress URL is not configured or disabled",
384
+ });
385
+ });
386
+
281
387
  test("phone readiness accepts configured public ingress", async () => {
282
388
  mockHasTwilioCredentials = true;
283
389
  setConfig("twilio", { phoneNumber: "+15550123" });
@@ -33,8 +33,14 @@ mock.module("../security/secure-keys.js", () => ({
33
33
  const originalFetch = globalThis.fetch;
34
34
  const originalEnvCredential = process.env.ASSISTANT_API_KEY;
35
35
 
36
- const { registerCallbackRoute, resolvePlatformCallbackRegistrationContext } =
37
- await import("../inbound/platform-callback-registration.js");
36
+ const {
37
+ registerCallbackRoute,
38
+ resolveCallbackUrl,
39
+ resolvePlatformCallbackRegistrationContext,
40
+ } = await import("../inbound/platform-callback-registration.js");
41
+
42
+ const { PublicIngressDisabledError } =
43
+ await import("../inbound/public-ingress-urls.js");
38
44
 
39
45
  describe("platform callback registration", () => {
40
46
  beforeEach(() => {
@@ -150,3 +156,127 @@ describe("platform callback registration", () => {
150
156
  ).resolves.toBe("https://platform.example.com/v1/gateway/callbacks/x/");
151
157
  });
152
158
  });
159
+
160
+ /**
161
+ * `resolveCallbackUrl` drives Twilio voice/status callbacks and OAuth redirect
162
+ * URIs. Its tier order has to match `handleWebhooksRegister` in
163
+ * `runtime/routes/webhook-routes.ts` and `hasWebhookRoutingConfigured` in
164
+ * `config/webhook-routing.ts` (LUM-2882).
165
+ */
166
+ describe("resolveCallbackUrl resolution order", () => {
167
+ const PLATFORM_URL = "https://platform.example.com/v1/gateway/callbacks/x/";
168
+
169
+ /** Stand in for a URL builder that cannot resolve a public ingress URL. */
170
+ function noIngress(): string {
171
+ throw new Error(
172
+ "No public base URL configured. Set ingress.publicBaseUrl in config.",
173
+ );
174
+ }
175
+
176
+ /** Stand in for a URL builder reached while ingress is switched off. */
177
+ function ingressDisabled(): string {
178
+ throw new PublicIngressDisabledError();
179
+ }
180
+
181
+ function seedPlatformCredentials(): void {
182
+ mockSecureKeys[credentialKey("vellum", "platform_base_url")] =
183
+ "https://platform.example.com";
184
+ mockSecureKeys[credentialKey("vellum", "platform_assistant_id")] =
185
+ "11111111-2222-4333-8444-555555555555";
186
+ mockSecureKeys[credentialKey("vellum", "assistant_api_key")] =
187
+ "ast-managed-key";
188
+ }
189
+
190
+ let registerCalls: number;
191
+
192
+ beforeEach(() => {
193
+ mockIsPlatform = false;
194
+ mockPlatformBaseUrl = "";
195
+ mockPlatformAssistantId = "";
196
+ mockSecureKeys = {};
197
+ delete process.env.ASSISTANT_API_KEY;
198
+ registerCalls = 0;
199
+ globalThis.fetch = mock(async () => {
200
+ registerCalls++;
201
+ return new Response(
202
+ JSON.stringify({
203
+ callback_url: PLATFORM_URL,
204
+ callback_path: "webhooks/twilio/voice",
205
+ type: "twilio_voice",
206
+ assistant_id: "11111111-2222-4333-8444-555555555555",
207
+ }),
208
+ { status: 201, headers: { "content-type": "application/json" } },
209
+ );
210
+ }) as unknown as typeof fetch;
211
+ });
212
+
213
+ afterEach(() => {
214
+ globalThis.fetch = originalFetch;
215
+ });
216
+
217
+ test("platform pods register with the platform gateway", async () => {
218
+ mockIsPlatform = true;
219
+ seedPlatformCredentials();
220
+
221
+ // The direct supplier is never evaluated on a pod: there is no ingress of
222
+ // its own to advertise, and evaluating it would throw.
223
+ await expect(
224
+ resolveCallbackUrl(noIngress, "webhooks/twilio/voice", "twilio_voice"),
225
+ ).resolves.toBe(PLATFORM_URL);
226
+ expect(registerCalls).toBe(1);
227
+ });
228
+
229
+ test("a configured ingress wins over platform connectivity", async () => {
230
+ seedPlatformCredentials();
231
+
232
+ await expect(
233
+ resolveCallbackUrl(
234
+ () => "https://tunnel.example.com/webhooks/twilio/voice",
235
+ "webhooks/twilio/voice",
236
+ "twilio_voice",
237
+ ),
238
+ ).resolves.toBe("https://tunnel.example.com/webhooks/twilio/voice");
239
+ expect(registerCalls).toBe(0);
240
+ });
241
+
242
+ test("a platform-connected assistant with no ingress registers with the platform", async () => {
243
+ // LUM-2882: this used to return the direct builder's throw because the
244
+ // platform branch was gated on IS_PLATFORM, which is only true on a pod.
245
+ seedPlatformCredentials();
246
+
247
+ await expect(
248
+ resolveCallbackUrl(noIngress, "webhooks/twilio/voice", "twilio_voice"),
249
+ ).resolves.toBe(PLATFORM_URL);
250
+ expect(registerCalls).toBe(1);
251
+ });
252
+
253
+ test("query parameters are appended to the platform URL", async () => {
254
+ seedPlatformCredentials();
255
+
256
+ await expect(
257
+ resolveCallbackUrl(noIngress, "webhooks/twilio/voice", "twilio_voice", {
258
+ callSessionId: "conv-xyz",
259
+ }),
260
+ ).resolves.toBe(`${PLATFORM_URL}?callSessionId=conv-xyz`);
261
+ });
262
+
263
+ test("an explicit ingress opt-out is not routed around", async () => {
264
+ seedPlatformCredentials();
265
+
266
+ await expect(
267
+ resolveCallbackUrl(
268
+ ingressDisabled,
269
+ "webhooks/twilio/voice",
270
+ "twilio_voice",
271
+ ),
272
+ ).rejects.toThrow("Public ingress is disabled");
273
+ expect(registerCalls).toBe(0);
274
+ });
275
+
276
+ test("without platform credentials the ingress error surfaces unchanged", async () => {
277
+ await expect(
278
+ resolveCallbackUrl(noIngress, "webhooks/twilio/voice", "twilio_voice"),
279
+ ).rejects.toThrow("No public base URL configured");
280
+ expect(registerCalls).toBe(0);
281
+ });
282
+ });
@@ -103,6 +103,12 @@ const BASELINE: Record<string, readonly string[]> = {
103
103
  "../../../../config/assistant-feature-flags.js",
104
104
  "../../../../config/default-profile-catalog.js",
105
105
  "../../../../config/loader.js",
106
+ // Sibling of the already-sanctioned memory-v3-gate: `memoryTier()` is
107
+ // defined as fully derived from that module's predicates, and the stats
108
+ // route reports both (`graph_supported` is exactly `tier === "v3"`).
109
+ // Re-deriving the tier inside the plugin would reintroduce exactly the
110
+ // drift the shared module exists to prevent. No plugin-api equivalent.
111
+ "../../../../config/memory-tier.js",
106
112
  "../../../../config/memory-v3-gate.js",
107
113
  "../../../../config/schema.js",
108
114
  "../../../../config/schemas/memory-v2.js",
@@ -1,5 +1,11 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import {
4
+ invalidateConfigCache,
5
+ loadRawConfig,
6
+ saveRawConfig,
7
+ setNestedValue,
8
+ } from "../config/loader.js";
3
9
  import { credentialKey } from "../security/credential-key.js";
4
10
 
5
11
  let secureKeyStore: Record<string, string> = {};
@@ -8,9 +14,28 @@ let oauthConnectionStore: Record<
8
14
  { id: string; status: string; accountInfo?: string | null }
9
15
  > = {};
10
16
  const syncCalls: Array<{ provider: string; accountInfo?: string }> = [];
17
+ let platformContextEnabled = false;
11
18
 
19
+ const registerCallbackRouteMock = mock(
20
+ async (callbackPath: string, _type: string) =>
21
+ `https://gateway.vellum.ai/assistant-123/${callbackPath}`,
22
+ );
23
+
24
+ // Spread the real module: it is a shared barrel and replacing it wholesale
25
+ // breaks unrelated importers pulled in by the module under test.
26
+ const actualPlatformCallbackRegistration =
27
+ await import("../inbound/platform-callback-registration.js");
12
28
  mock.module("../inbound/platform-callback-registration.js", () => ({
13
- registerCallbackRoute: async () => {},
29
+ ...actualPlatformCallbackRegistration,
30
+ registerCallbackRoute: registerCallbackRouteMock,
31
+ resolvePlatformCallbackRegistrationContext: async () => ({
32
+ isPlatform: false,
33
+ platformBaseUrl: platformContextEnabled ? "https://api.vellum.ai" : "",
34
+ assistantId: platformContextEnabled ? "assistant-123" : "",
35
+ hasAssistantApiKey: platformContextEnabled,
36
+ authHeader: platformContextEnabled ? "Api-Key secret" : null,
37
+ enabled: platformContextEnabled,
38
+ }),
14
39
  }));
15
40
 
16
41
  mock.module("../daemon/handlers/shared.js", () => ({
@@ -77,13 +102,37 @@ mock.module("../tools/credentials/metadata-store.js", () => ({
77
102
 
78
103
  const originalFetch = globalThis.fetch;
79
104
 
80
- import { getTelegramConfig } from "../daemon/handlers/config-telegram.js";
105
+ const { getTelegramConfig, setTelegramConfig } =
106
+ await import("../daemon/handlers/config-telegram.js");
107
+
108
+ function mockTelegramApi(): typeof fetch {
109
+ return (async (input: RequestInfo | URL) => {
110
+ const url = String(input);
111
+ if (url.includes("/getMe")) {
112
+ return new Response(
113
+ JSON.stringify({ ok: true, result: { id: 42, username: "testbot" } }),
114
+ { status: 200 },
115
+ );
116
+ }
117
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
118
+ }) as typeof fetch;
119
+ }
120
+
121
+ function setIngressPublicBaseUrl(url: string): void {
122
+ const raw = loadRawConfig();
123
+ setNestedValue(raw, "ingress.publicBaseUrl", url);
124
+ saveRawConfig(raw);
125
+ invalidateConfigCache();
126
+ }
81
127
 
82
128
  describe("Telegram config handler", () => {
83
129
  beforeEach(() => {
84
130
  secureKeyStore = {};
85
131
  oauthConnectionStore = {};
86
132
  syncCalls.length = 0;
133
+ platformContextEnabled = false;
134
+ registerCallbackRouteMock.mockClear();
135
+ setIngressPublicBaseUrl("");
87
136
  globalThis.fetch = originalFetch;
88
137
  });
89
138
 
@@ -105,4 +154,49 @@ describe("Telegram config handler", () => {
105
154
  ]);
106
155
  expect(oauthConnectionStore["telegram"]?.accountInfo).toBe("@testbot");
107
156
  });
157
+
158
+ // A platform-connected local assistant (IS_PLATFORM unset, valid platform
159
+ // credentials, no public ingress) receives Telegram webhooks only through
160
+ // managed platform callbacks, so saving the bot token must register the
161
+ // route.
162
+ test("set registers the platform callback route for a platform-connected local assistant", async () => {
163
+ platformContextEnabled = true;
164
+ globalThis.fetch = mockTelegramApi();
165
+
166
+ const result = await setTelegramConfig(
167
+ "123456789:AAtesttoken_testtoken_testtoken_test",
168
+ );
169
+
170
+ expect(result.success).toBe(true);
171
+ expect(registerCallbackRouteMock).toHaveBeenCalledWith(
172
+ "webhooks/telegram",
173
+ "telegram",
174
+ );
175
+ });
176
+
177
+ test("set does not register a platform callback route when not platform-connected", async () => {
178
+ globalThis.fetch = mockTelegramApi();
179
+
180
+ const result = await setTelegramConfig(
181
+ "123456789:AAtesttoken_testtoken_testtoken_test",
182
+ );
183
+
184
+ expect(result.success).toBe(true);
185
+ expect(registerCallbackRouteMock).not.toHaveBeenCalled();
186
+ });
187
+
188
+ // A logged-in local assistant holds platform credentials for the LLM proxy,
189
+ // so credential presence must not override an explicitly configured ingress.
190
+ test("set does not register a platform callback route when a public ingress is configured", async () => {
191
+ platformContextEnabled = true;
192
+ setIngressPublicBaseUrl("https://abc.ngrok.io");
193
+ globalThis.fetch = mockTelegramApi();
194
+
195
+ const result = await setTelegramConfig(
196
+ "123456789:AAtesttoken_testtoken_testtoken_test",
197
+ );
198
+
199
+ expect(result.success).toBe(true);
200
+ expect(registerCallbackRouteMock).not.toHaveBeenCalled();
201
+ });
108
202
  });