@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.
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ buildSlackMessageDeepLinks,
5
+ buildSlackWebChannelUrl,
6
+ } from "./deep-link.js";
7
+
8
+ describe("buildSlackMessageDeepLinks", () => {
9
+ test("builds workspace-branded links when team identity is configured", () => {
10
+ expect(
11
+ buildSlackMessageDeepLinks({
12
+ teamId: "T123",
13
+ teamUrl: "https://example.slack.com",
14
+ channelId: "C123",
15
+ messageTs: "1710000000.000200",
16
+ threadTs: "1710000000.000100",
17
+ }),
18
+ ).toEqual({
19
+ appUrl: "slack://channel?team=T123&id=C123&message=1710000000.000200",
20
+ webUrl:
21
+ "https://example.slack.com/archives/C123/p1710000000000200?thread_ts=1710000000.000100&cid=C123",
22
+ });
23
+ });
24
+
25
+ test("falls back to the workspace-agnostic permalink without a teamUrl", () => {
26
+ expect(
27
+ buildSlackMessageDeepLinks({
28
+ teamId: "",
29
+ teamUrl: "",
30
+ channelId: "C123",
31
+ messageTs: "1710000000.000200",
32
+ threadTs: "1710000000.000100",
33
+ }),
34
+ ).toEqual({
35
+ webUrl:
36
+ "https://slack.com/archives/C123/p1710000000000200?thread_ts=1710000000.000100&cid=C123",
37
+ });
38
+ });
39
+
40
+ test("fallback permalink omits thread params for a thread root", () => {
41
+ expect(
42
+ buildSlackMessageDeepLinks({
43
+ channelId: "C123",
44
+ messageTs: "1710000000.000100",
45
+ threadTs: "1710000000.000100",
46
+ }),
47
+ ).toEqual({
48
+ webUrl: "https://slack.com/archives/C123/p1710000000000100",
49
+ });
50
+ });
51
+
52
+ test("keeps the slack:// app link when only the teamUrl is missing", () => {
53
+ expect(
54
+ buildSlackMessageDeepLinks({
55
+ teamId: "T123",
56
+ channelId: "C123",
57
+ messageTs: "1710000000.000100",
58
+ }),
59
+ ).toEqual({
60
+ appUrl: "slack://channel?team=T123&id=C123&message=1710000000.000100",
61
+ webUrl: "https://slack.com/archives/C123/p1710000000000100",
62
+ });
63
+ });
64
+
65
+ test("rejects a non-https teamUrl and falls back to slack.com", () => {
66
+ expect(
67
+ buildSlackMessageDeepLinks({
68
+ teamUrl: "http://example.slack.com",
69
+ channelId: "C123",
70
+ messageTs: "1710000000.000100",
71
+ }).webUrl,
72
+ ).toBe("https://slack.com/archives/C123/p1710000000000100");
73
+ });
74
+ });
75
+
76
+ describe("buildSlackWebChannelUrl", () => {
77
+ test("uses the workspace URL when configured", () => {
78
+ expect(
79
+ buildSlackWebChannelUrl({
80
+ teamUrl: "https://example.slack.com",
81
+ channelId: "C123",
82
+ }),
83
+ ).toBe("https://example.slack.com/archives/C123");
84
+ });
85
+
86
+ test("falls back to slack.com without a teamUrl", () => {
87
+ expect(buildSlackWebChannelUrl({ channelId: "C123" })).toBe(
88
+ "https://slack.com/archives/C123",
89
+ );
90
+ });
91
+ });
@@ -94,28 +94,47 @@ export function buildSlackPermalink(params: {
94
94
  );
95
95
  }
96
96
 
97
+ /**
98
+ * Web URL for a channel. Workspace-branded when the team URL is known,
99
+ * otherwise the workspace-agnostic `https://slack.com/archives/…` form —
100
+ * Slack resolves the channel id to the right workspace for any
101
+ * authenticated viewer (the web client already synthesizes this exact
102
+ * shape from message permalinks, see `getSlackChannelLinkFromMessageLink`
103
+ * in `clients/web`).
104
+ */
97
105
  export function buildSlackWebChannelUrl(params: {
98
106
  teamUrl?: string | null;
99
107
  channelId: string;
100
- }): string | undefined {
101
- const teamUrl = normalizeSlackTeamUrl(params.teamUrl);
102
- if (!teamUrl) return undefined;
103
-
108
+ }): string {
109
+ const teamUrl = normalizeSlackTeamUrl(params.teamUrl) ?? "https://slack.com";
104
110
  return `${teamUrl}/archives/${encodeURIComponent(params.channelId)}`;
105
111
  }
106
112
 
113
+ /**
114
+ * Deep-link pair for a message. The web URL always exists: it is
115
+ * workspace-branded when the team URL is configured and otherwise falls
116
+ * back to the workspace-agnostic `buildSlackPermalink` form, so installs
117
+ * that never learned their workspace identity (e.g. gateway-connected
118
+ * Slack) still get working links. The `slack://` app URL still requires
119
+ * a known team id.
120
+ */
107
121
  export function buildSlackMessageDeepLinks(params: {
108
122
  teamId?: string | null;
109
123
  teamUrl?: string | null;
110
124
  channelId: string;
111
125
  messageTs: string;
112
126
  threadTs?: string;
113
- }): SlackMessageDeepLinks | undefined {
127
+ }): SlackMessageDeepLinks {
114
128
  const appUrl = buildSlackAppMessageUrl(params);
115
- const webUrl = buildSlackWebMessageUrl(params);
116
- if (!appUrl && !webUrl) return undefined;
129
+ const webUrl =
130
+ buildSlackWebMessageUrl(params) ??
131
+ buildSlackPermalink({
132
+ channelId: params.channelId,
133
+ messageTs: params.messageTs,
134
+ ...(params.threadTs ? { threadTs: params.threadTs } : {}),
135
+ });
117
136
  return {
118
137
  ...(appUrl ? { appUrl } : {}),
119
- ...(webUrl ? { webUrl } : {}),
138
+ webUrl,
120
139
  };
121
140
  }
@@ -1407,5 +1407,27 @@ describe("Memory Item Routes", () => {
1407
1407
  };
1408
1408
  expect(body.graph_supported).toBe(false);
1409
1409
  });
1410
+
1411
+ // `tier` is what lets a client explain an unavailable graph instead of
1412
+ // stating a bare "not available": the opt-out and a legacy engine are
1413
+ // different problems with different fixes.
1414
+ test.each([
1415
+ ["off", { enabled: false, v3: { live: true } }],
1416
+ ["v3", { enabled: true, v3: { live: true } }],
1417
+ ["v2", { enabled: true, v2: { enabled: true }, v3: { live: false } }],
1418
+ ["v1", { enabled: true, v2: { enabled: false }, v3: { live: false } }],
1419
+ ] as const)("reports tier %s", async (tier, memory) => {
1420
+ setConfig("memory", memory);
1421
+ const res = await callHandler(route);
1422
+ expect(res.status).toBe(200);
1423
+ const body = (await res.json()) as {
1424
+ tier: string;
1425
+ graph_supported: boolean;
1426
+ };
1427
+ expect(body.tier).toBe(tier);
1428
+ // The capability bit and its explanation are derived from the same gate,
1429
+ // so they can never disagree.
1430
+ expect(body.graph_supported).toBe(tier === "v3");
1431
+ });
1410
1432
  });
1411
1433
  });
@@ -32,6 +32,7 @@ import {
32
32
  import { z } from "zod";
33
33
 
34
34
  import { getConfig } from "../../../../config/loader.js";
35
+ import { type MemoryTier, memoryTier } from "../../../../config/memory-tier.js";
35
36
  import {
36
37
  isV3TierActive,
37
38
  usesConceptPageMemory,
@@ -523,13 +524,24 @@ function handleGetMemoryItem(id: string) {
523
524
  * `GET /memory-graph` returns `supported: true` (memory enabled + v3 live). It
524
525
  * is a cheap config read (no page I/O), so glanceable surfaces can gate the
525
526
  * graph entry point on real availability without triggering the graph build.
527
+ *
528
+ * `tier` reports WHY the graph is or isn't available, so a client can say
529
+ * something true instead of a bare "not available": `"off"` is the user's own
530
+ * Memory opt-out (fixed in Settings), while `"v1"`/`"v2"` are legacy engines
531
+ * (fixed by migrating to v3). Both bits derive from the same gate predicates —
532
+ * `graph_supported` is exactly `tier === "v3"` (see `memory-tier.ts`) — so the
533
+ * capability and its explanation can never disagree.
526
534
  */
527
535
  async function handleGetMemoryStats(
528
536
  config: AssistantConfig,
529
- ): Promise<{ concepts: number; graph_supported: boolean }> {
537
+ ): Promise<{ concepts: number; graph_supported: boolean; tier: MemoryTier }> {
530
538
  const pageIndex = await getPageIndex(getWorkspaceDir());
531
539
  const concepts = pageIndex.entries.filter((e) => e.modifiedAt > 0).length;
532
- return { concepts, graph_supported: isV3TierActive(config) };
540
+ return {
541
+ concepts,
542
+ graph_supported: isV3TierActive(config),
543
+ tier: memoryTier(config),
544
+ };
533
545
  }
534
546
 
535
547
  async function handleCreateMemoryItem(body: Record<string, unknown>) {
@@ -864,7 +876,9 @@ export const ROUTES: RouteDefinition[] = [
864
876
  "concept pages only and never builds the memory-concept graph. Also " +
865
877
  "reports graph_supported: whether the memory-concept graph is available " +
866
878
  "for this assistant (memory enabled and v3 live), so callers can gate " +
867
- "the graph entry point without building the graph.",
879
+ "the graph entry point without building the graph, plus tier: the coarse " +
880
+ "memory tier explaining why the graph is unavailable (off = the user's " +
881
+ "Memory opt-out, v1/v2 = a legacy engine that has not migrated to v3).",
868
882
  tags: ["memory"],
869
883
  responseBody: z.object({
870
884
  concepts: z.number().describe("Number of concept pages in memory"),
@@ -873,6 +887,11 @@ export const ROUTES: RouteDefinition[] = [
873
887
  .describe(
874
888
  "Whether the memory-concept graph is available (memory enabled and v3 live)",
875
889
  ),
890
+ tier: z
891
+ .enum(["off", "v1", "v2", "v3"])
892
+ .describe(
893
+ "Coarse memory tier for this assistant; graph_supported is exactly tier === 'v3'",
894
+ ),
876
895
  }),
877
896
  handler: () => handleGetMemoryStats(getConfig()),
878
897
  },
@@ -140,14 +140,12 @@ async function checkCredential(
140
140
  }
141
141
 
142
142
  /** Check that public ingress is configured and enabled. */
143
- function checkIngress(
143
+ async function checkIngress(
144
144
  allowManagedCallbacks = false,
145
145
  options: { twilio?: boolean } = {},
146
- ): ReadinessCheckResult {
147
- const { configured, usesManagedCallbacks } = hasWebhookRoutingConfigured(
148
- allowManagedCallbacks,
149
- options,
150
- );
146
+ ): Promise<ReadinessCheckResult> {
147
+ const { configured, usesManagedCallbacks } =
148
+ await hasWebhookRoutingConfigured(allowManagedCallbacks, options);
151
149
  return check(
152
150
  "ingress",
153
151
  configured,
@@ -173,7 +171,7 @@ const voiceProbe: ChannelProbe = {
173
171
  async runLocalChecks(): Promise<ReadinessCheckResult[]> {
174
172
  const hasCreds = await hasTwilioCredentials();
175
173
  const hasPhone = !!resolveTwilioPhoneNumber();
176
- const ingress = checkIngress(true, { twilio: true });
174
+ const ingress = await checkIngress(true, { twilio: true });
177
175
 
178
176
  return [
179
177
  check(
@@ -211,7 +209,7 @@ const telegramProbe: ChannelProbe = {
211
209
  "webhook_secret",
212
210
  "Telegram webhook secret",
213
211
  ),
214
- checkIngress(true),
212
+ await checkIngress(true),
215
213
  ];
216
214
  },
217
215
  };
@@ -234,7 +232,7 @@ const emailProbe: ChannelProbe = {
234
232
  "Email invite code redemption is enabled",
235
233
  "Email invite code redemption is disabled",
236
234
  ),
237
- checkIngress(),
235
+ await checkIngress(),
238
236
  ];
239
237
  },
240
238
  async runRemoteChecks(): Promise<ReadinessCheckResult[]> {
@@ -307,7 +305,7 @@ const whatsappProbe: ChannelProbe = {
307
305
  "WhatsApp invite code redemption is enabled",
308
306
  "WhatsApp invite code redemption is disabled",
309
307
  ),
310
- checkIngress(),
308
+ await checkIngress(),
311
309
  ];
312
310
  },
313
311
  };
@@ -19,7 +19,7 @@ import {
19
19
  } from "../../inbound/platform-callback-registration.js";
20
20
  import {
21
21
  getPublicBaseUrl,
22
- type IngressConfig,
22
+ isPublicIngressDisabled,
23
23
  } from "../../inbound/public-ingress-urls.js";
24
24
  import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
25
25
  import {
@@ -101,18 +101,6 @@ async function registerWithPlatform(
101
101
  return { callbackUrl, type, path: webhookPath, mode: "platform" };
102
102
  }
103
103
 
104
- /**
105
- * True when the user has explicitly switched public ingress off.
106
- *
107
- * An explicit opt-out is a decision not to accept inbound webhooks at all, so
108
- * it must not be silently routed around via platform callbacks. An *absent*
109
- * ingress config is merely "not set up yet" and is eligible for the platform
110
- * fallback.
111
- */
112
- function isPublicIngressDisabled(config: IngressConfig): boolean {
113
- return config.ingress?.enabled === false;
114
- }
115
-
116
104
  // ---------------------------------------------------------------------------
117
105
  // Handlers
118
106
  // ---------------------------------------------------------------------------
@@ -4,10 +4,14 @@ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
4
4
 
5
5
  const secureKeyValues = new Map<string, string>();
6
6
 
7
- let webhookRouting: { configured: boolean; usesManagedCallbacks: boolean } = {
8
- configured: true,
9
- usesManagedCallbacks: false,
10
- };
7
+ // Webhook routing is driven through its real inputs rather than by mocking
8
+ // `hasWebhookRoutingConfigured` itself. Stubbing the predicate would have let
9
+ // the sweep and the predicate drift apart unnoticed, which is exactly how
10
+ // LUM-2882 stayed invisible: the predicate stopped matching what
11
+ // `webhooks register` actually does for platform-connected local assistants.
12
+ let ingressConfig: Record<string, unknown> = {};
13
+ let isPlatform = false;
14
+ let platformContextEnabled = false;
11
15
 
12
16
  type FetchOutcome =
13
17
  | { kind: "json"; status?: number; body: unknown }
@@ -34,11 +38,6 @@ mock.module("../../security/credential-key.js", () => ({
34
38
  `credential/${service}/${field}`,
35
39
  }));
36
40
 
37
- mock.module("../../config/webhook-routing.js", () => ({
38
- hasWebhookRoutingConfigured: () => webhookRouting,
39
- hasIngressConfigured: () => webhookRouting.configured,
40
- }));
41
-
42
41
  mock.module("../bot-username.js", () => ({
43
42
  getTelegramBotUsername: () => "test_bot",
44
43
  getTelegramBotId: () => "123",
@@ -46,8 +45,33 @@ mock.module("../bot-username.js", () => ({
46
45
 
47
46
  let apiBaseUrl = "https://api.telegram.org";
48
47
 
48
+ // Spread the real modules below: these are broad barrels shared with peer test
49
+ // files, and replacing one wholesale drops the exports those files import.
50
+ const actualLoader = await import("../../config/loader.js");
49
51
  mock.module("../../config/loader.js", () => ({
50
- getConfig: () => ({ telegram: { apiBaseUrl } }),
52
+ ...actualLoader,
53
+ getConfig: () => ({ telegram: { apiBaseUrl }, ingress: ingressConfig }),
54
+ loadRawConfig: () => ({ ingress: ingressConfig }),
55
+ }));
56
+
57
+ const actualEnvRegistry = await import("../../config/env-registry.js");
58
+ mock.module("../../config/env-registry.js", () => ({
59
+ ...actualEnvRegistry,
60
+ getIsPlatform: () => isPlatform,
61
+ }));
62
+
63
+ const actualRegistration =
64
+ await import("../../inbound/platform-callback-registration.js");
65
+ mock.module("../../inbound/platform-callback-registration.js", () => ({
66
+ ...actualRegistration,
67
+ resolvePlatformCallbackRegistrationContext: async () => ({
68
+ isPlatform,
69
+ platformBaseUrl: "https://api.vellum.ai",
70
+ assistantId: platformContextEnabled ? "assistant-123" : "",
71
+ hasAssistantApiKey: platformContextEnabled,
72
+ authHeader: platformContextEnabled ? "Api-Key secret" : null,
73
+ enabled: platformContextEnabled,
74
+ }),
51
75
  }));
52
76
 
53
77
  // Mirrors the real `emitNotificationSignal` contract: it swallows pipeline
@@ -121,7 +145,9 @@ beforeEach(() => {
121
145
  secureKeyValues.clear();
122
146
  secureKeyValues.set(BOT_TOKEN_KEY, "12345:test-token");
123
147
  secureKeyValues.set(WEBHOOK_SECRET_KEY, "s3cret");
124
- webhookRouting = { configured: true, usesManagedCallbacks: false };
148
+ ingressConfig = { publicBaseUrl: "https://example.test" };
149
+ isPlatform = false;
150
+ platformContextEnabled = false;
125
151
  setWebhookInfo({ url: WEBHOOK_URL, pending_update_count: 0 });
126
152
  fetchCallCount = 0;
127
153
  fetchedUrls.length = 0;
@@ -158,7 +184,20 @@ describe("gating", () => {
158
184
  });
159
185
 
160
186
  test("does not run when no webhook routing is configured", async () => {
161
- webhookRouting = { configured: false, usesManagedCallbacks: false };
187
+ ingressConfig = {};
188
+
189
+ const result = await runTelegramWebhookHealthCheck();
190
+
191
+ expect(result.status).toBe("skipped");
192
+ expect(fetchCallCount).toBe(0);
193
+ expect(emittedSignals).toHaveLength(0);
194
+ });
195
+
196
+ test("does not run when public ingress is explicitly disabled", async () => {
197
+ // An opt-out means no inbound webhook is expected at all, so the sweep has
198
+ // nothing to verify even though platform credentials are present.
199
+ ingressConfig = { enabled: false };
200
+ platformContextEnabled = true;
162
201
 
163
202
  const result = await runTelegramWebhookHealthCheck();
164
203
 
@@ -167,8 +206,32 @@ describe("gating", () => {
167
206
  expect(emittedSignals).toHaveLength(0);
168
207
  });
169
208
 
209
+ test("runs for a platform-connected local assistant with no ingress", async () => {
210
+ // LUM-2882: `webhooks register telegram` registers a platform callback
211
+ // route in this exact configuration, so a broken registration is real and
212
+ // the sweep has to verify it rather than skip.
213
+ ingressConfig = {};
214
+ platformContextEnabled = true;
215
+ setWebhookInfo({
216
+ url: WEBHOOK_URL,
217
+ last_error_date: unixSecondsAgo(30),
218
+ last_error_message: "Wrong response from the webhook: 404 Not Found",
219
+ });
220
+
221
+ const result = await runTelegramWebhookHealthCheck();
222
+
223
+ expect(result.status).toBe("delivery_failing");
224
+ expect(fetchCallCount).toBeGreaterThan(0);
225
+ expect(emittedSignals).toHaveLength(1);
226
+ // The callback route is platform-owned, so the self-hosted remediation
227
+ // (point config at a new tunnel URL) does not apply.
228
+ expect(result.detail).not.toContain("assistant config set");
229
+ expect(result.detail).toContain("contact support");
230
+ });
231
+
170
232
  test("runs when platform-managed callbacks stand in for public ingress", async () => {
171
- webhookRouting = { configured: true, usesManagedCallbacks: true };
233
+ isPlatform = true;
234
+ ingressConfig = {};
172
235
  setWebhookInfo({
173
236
  url: WEBHOOK_URL,
174
237
  last_error_date: unixSecondsAgo(30),
@@ -191,7 +191,7 @@ export async function checkTelegramWebhookHealth(): Promise<TelegramWebhookHealt
191
191
  }
192
192
 
193
193
  const { configured, usesManagedCallbacks } =
194
- hasWebhookRoutingConfigured(true);
194
+ await hasWebhookRoutingConfigured(true);
195
195
  if (!configured) {
196
196
  return {
197
197
  status: "skipped",