@vellumai/vellum-gateway 0.5.15 → 0.6.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.
- package/package.json +1 -1
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +59 -5
- package/src/__tests__/feature-flags-route.test.ts +57 -29
- package/src/__tests__/remote-feature-flag-sync.test.ts +86 -6
- package/src/__tests__/slack-normalize.test.ts +26 -0
- package/src/__tests__/telegram-webhook-manager.test.ts +189 -0
- package/src/auth/scopes.ts +1 -0
- package/src/auth/types.ts +1 -0
- package/src/config.ts +34 -11
- package/src/credential-watcher.ts +30 -3
- package/src/feature-flag-registry.json +29 -116
- package/src/http/routes/browser-relay-websocket.ts +1 -22
- package/src/http/routes/channel-verification-session-proxy.ts +18 -2
- package/src/http/routes/runtime-proxy.ts +7 -1
- package/src/index.ts +56 -41
- package/src/remote-feature-flag-sync.ts +21 -8
- package/src/runtime/client.ts +10 -5
- package/src/schema.ts +6 -1
- package/src/slack/dm-context.test.ts +170 -0
- package/src/slack/dm-context.ts +135 -0
- package/src/slack/normalize.test.ts +117 -0
- package/src/slack/normalize.ts +9 -6
- package/src/telegram/webhook-manager.ts +33 -13
- package/src/util/is-loopback-address.ts +21 -0
package/package.json
CHANGED
|
@@ -76,7 +76,7 @@ async function startGateway(): Promise<void> {
|
|
|
76
76
|
stdio: ["ignore", "pipe", "pipe"],
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
-
const deadline = Date.now() +
|
|
79
|
+
const deadline = Date.now() + 10_000;
|
|
80
80
|
while (Date.now() < deadline) {
|
|
81
81
|
try {
|
|
82
82
|
const res = await fetch(`http://localhost:${gatewayPort}/healthz`);
|
|
@@ -86,7 +86,7 @@ async function startGateway(): Promise<void> {
|
|
|
86
86
|
}
|
|
87
87
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
88
88
|
}
|
|
89
|
-
throw new Error("Gateway failed to start within
|
|
89
|
+
throw new Error("Gateway failed to start within 10 seconds");
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
function startFakeCes(opts: {
|
|
@@ -139,7 +139,7 @@ afterEach(() => {
|
|
|
139
139
|
cesPort = 0;
|
|
140
140
|
|
|
141
141
|
if (gatewayProc) {
|
|
142
|
-
gatewayProc.kill();
|
|
142
|
+
gatewayProc.kill("SIGKILL");
|
|
143
143
|
gatewayProc = null;
|
|
144
144
|
}
|
|
145
145
|
|
|
@@ -176,7 +176,7 @@ describe("gateway managed credential bootstrap retry", () => {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
expect(status).toBe(401);
|
|
179
|
-
},
|
|
179
|
+
}, 20_000);
|
|
180
180
|
|
|
181
181
|
test("keeps retrying until configured credential reads succeed after CES list is already available", async () => {
|
|
182
182
|
mkdirSync(testDir, { recursive: true });
|
|
@@ -220,5 +220,59 @@ describe("gateway managed credential bootstrap retry", () => {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
expect(status).toBe(401);
|
|
223
|
-
},
|
|
223
|
+
}, 20_000);
|
|
224
|
+
|
|
225
|
+
test("detects new Slack credentials when metadata is written after startup with no initial channel services", async () => {
|
|
226
|
+
// Start with NO channel service metadata — only non-channel credentials
|
|
227
|
+
// (simulates a fresh assistant that has platform credentials but no channels).
|
|
228
|
+
mkdirSync(testDir, { recursive: true });
|
|
229
|
+
writeCredentialMetadata([
|
|
230
|
+
metadataRecord("api-key-1", "vellum", "assistant_api_key"),
|
|
231
|
+
]);
|
|
232
|
+
|
|
233
|
+
startFakeCes({
|
|
234
|
+
credentials: {
|
|
235
|
+
"credential/vellum/assistant_api_key": "fake-api-key",
|
|
236
|
+
"credential/slack_channel/bot_token": "xoxb-fake-bot-token",
|
|
237
|
+
"credential/slack_channel/app_token": "xapp-fake-app-token",
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await startGateway();
|
|
242
|
+
|
|
243
|
+
// Slack deliver should be 503 since no Slack credentials are configured.
|
|
244
|
+
const base = `http://localhost:${gatewayPort}`;
|
|
245
|
+
const before = await fetch(`${base}/deliver/slack`, {
|
|
246
|
+
method: "POST",
|
|
247
|
+
headers: { "content-type": "application/json" },
|
|
248
|
+
});
|
|
249
|
+
expect(before.status).toBe(503);
|
|
250
|
+
|
|
251
|
+
// Now write Slack channel metadata (simulating the daemon storing
|
|
252
|
+
// credentials after the user completes the Slack setup flow).
|
|
253
|
+
writeCredentialMetadata([
|
|
254
|
+
metadataRecord("api-key-1", "vellum", "assistant_api_key"),
|
|
255
|
+
metadataRecord("bt-1", "slack_channel", "bot_token"),
|
|
256
|
+
metadataRecord("at-1", "slack_channel", "app_token"),
|
|
257
|
+
]);
|
|
258
|
+
|
|
259
|
+
// The managed bootstrap retry should still be polling (since no channel
|
|
260
|
+
// services were configured at startup), so the new metadata should be
|
|
261
|
+
// detected within a few seconds even without fs.watch() working.
|
|
262
|
+
const deadline = Date.now() + 5_000;
|
|
263
|
+
let status = before.status;
|
|
264
|
+
while (Date.now() < deadline) {
|
|
265
|
+
const resp = await fetch(`${base}/deliver/slack`, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: { "content-type": "application/json" },
|
|
268
|
+
});
|
|
269
|
+
status = resp.status;
|
|
270
|
+
// 401 means Slack is configured but the deliver auth check failed
|
|
271
|
+
// (which is expected since we didn't provide a valid bearer token).
|
|
272
|
+
if (status === 401) break;
|
|
273
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
expect(status).toBe(401);
|
|
277
|
+
}, 20_000);
|
|
224
278
|
});
|
|
@@ -39,11 +39,11 @@ const TEST_REGISTRY = {
|
|
|
39
39
|
defaultEnabled: true,
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
|
-
id: "
|
|
42
|
+
id: "email-channel",
|
|
43
43
|
scope: "assistant",
|
|
44
|
-
key: "
|
|
45
|
-
label: "
|
|
46
|
-
description: "
|
|
44
|
+
key: "email-channel",
|
|
45
|
+
label: "Email Channel",
|
|
46
|
+
description: "Email channel integration",
|
|
47
47
|
defaultEnabled: false,
|
|
48
48
|
},
|
|
49
49
|
{
|
|
@@ -262,19 +262,19 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
262
262
|
});
|
|
263
263
|
|
|
264
264
|
test("remote values fill in when no local override exists", async () => {
|
|
265
|
-
// Write a remote store with
|
|
265
|
+
// Write a remote store with email-channel enabled (overriding registry default of false)
|
|
266
266
|
writeFileSync(
|
|
267
267
|
remoteFeatureFlagStorePath,
|
|
268
268
|
JSON.stringify({
|
|
269
269
|
version: 1,
|
|
270
270
|
values: {
|
|
271
|
-
|
|
271
|
+
"email-channel": true,
|
|
272
272
|
},
|
|
273
273
|
}),
|
|
274
274
|
);
|
|
275
275
|
clearRemoteFeatureFlagStoreCache();
|
|
276
276
|
|
|
277
|
-
// No local override for
|
|
277
|
+
// No local override for email-channel
|
|
278
278
|
if (existsSync(featureFlagStorePath)) {
|
|
279
279
|
rmSync(featureFlagStorePath);
|
|
280
280
|
}
|
|
@@ -288,12 +288,12 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
288
288
|
expect(res.status).toBe(200);
|
|
289
289
|
const body = await res.json();
|
|
290
290
|
|
|
291
|
-
const
|
|
292
|
-
(f: { key: string }) => f.key === "
|
|
291
|
+
const emailFlag = body.flags.find(
|
|
292
|
+
(f: { key: string }) => f.key === "email-channel",
|
|
293
293
|
);
|
|
294
|
-
expect(
|
|
294
|
+
expect(emailFlag).toBeDefined();
|
|
295
295
|
// Remote value (true) overrides registry default (false)
|
|
296
|
-
expect(
|
|
296
|
+
expect(emailFlag.enabled).toBe(true);
|
|
297
297
|
});
|
|
298
298
|
|
|
299
299
|
test("local overrides take precedence over remote values", async () => {
|
|
@@ -303,7 +303,7 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
303
303
|
JSON.stringify({
|
|
304
304
|
version: 1,
|
|
305
305
|
values: {
|
|
306
|
-
|
|
306
|
+
"email-channel": true,
|
|
307
307
|
},
|
|
308
308
|
}),
|
|
309
309
|
);
|
|
@@ -315,7 +315,7 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
315
315
|
JSON.stringify({
|
|
316
316
|
version: 1,
|
|
317
317
|
values: {
|
|
318
|
-
|
|
318
|
+
"email-channel": false,
|
|
319
319
|
},
|
|
320
320
|
}),
|
|
321
321
|
);
|
|
@@ -329,12 +329,12 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
329
329
|
expect(res.status).toBe(200);
|
|
330
330
|
const body = await res.json();
|
|
331
331
|
|
|
332
|
-
const
|
|
333
|
-
(f: { key: string }) => f.key === "
|
|
332
|
+
const emailFlag = body.flags.find(
|
|
333
|
+
(f: { key: string }) => f.key === "email-channel",
|
|
334
334
|
);
|
|
335
|
-
expect(
|
|
335
|
+
expect(emailFlag).toBeDefined();
|
|
336
336
|
// Local override (false) takes precedence over remote (true)
|
|
337
|
-
expect(
|
|
337
|
+
expect(emailFlag.enabled).toBe(false);
|
|
338
338
|
});
|
|
339
339
|
|
|
340
340
|
test("registry default used when neither local nor remote is set", async () => {
|
|
@@ -358,13 +358,13 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
358
358
|
expect(res.status).toBe(200);
|
|
359
359
|
const body = await res.json();
|
|
360
360
|
|
|
361
|
-
//
|
|
362
|
-
const
|
|
363
|
-
(f: { key: string }) => f.key === "
|
|
361
|
+
// email-channel has defaultEnabled: false in registry
|
|
362
|
+
const emailFlag = body.flags.find(
|
|
363
|
+
(f: { key: string }) => f.key === "email-channel",
|
|
364
364
|
);
|
|
365
|
-
expect(
|
|
366
|
-
expect(
|
|
367
|
-
expect(
|
|
365
|
+
expect(emailFlag).toBeDefined();
|
|
366
|
+
expect(emailFlag.enabled).toBe(false);
|
|
367
|
+
expect(emailFlag.defaultEnabled).toBe(false);
|
|
368
368
|
|
|
369
369
|
// browser has defaultEnabled: true in registry
|
|
370
370
|
const browserFlag = body.flags.find(
|
|
@@ -374,6 +374,34 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
374
374
|
expect(browserFlag.enabled).toBe(true);
|
|
375
375
|
expect(browserFlag.defaultEnabled).toBe(true);
|
|
376
376
|
});
|
|
377
|
+
|
|
378
|
+
test("returns flags when invoked via assistants path without trailing slash", async () => {
|
|
379
|
+
// The macOS client sends GET /v1/assistants/<id>/feature-flags (no trailing slash).
|
|
380
|
+
// The gateway route regex must accept this path.
|
|
381
|
+
const handler = createFeatureFlagsGetHandler();
|
|
382
|
+
const res = await handler(
|
|
383
|
+
new Request(
|
|
384
|
+
"http://gateway.test/v1/assistants/some-assistant-id/feature-flags",
|
|
385
|
+
),
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
expect(res.status).toBe(200);
|
|
389
|
+
const body = await res.json();
|
|
390
|
+
|
|
391
|
+
// Should return all assistant-scope flags with expected shape
|
|
392
|
+
expect(body.flags.length).toBeGreaterThan(0);
|
|
393
|
+
for (const flag of body.flags) {
|
|
394
|
+
expect(typeof flag.key).toBe("string");
|
|
395
|
+
expect(typeof flag.enabled).toBe("boolean");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Verify a known flag is present
|
|
399
|
+
const browserFlag = body.flags.find(
|
|
400
|
+
(f: { key: string }) => f.key === "browser",
|
|
401
|
+
);
|
|
402
|
+
expect(browserFlag).toBeDefined();
|
|
403
|
+
expect(browserFlag.enabled).toBe(true);
|
|
404
|
+
});
|
|
377
405
|
});
|
|
378
406
|
|
|
379
407
|
describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
@@ -408,7 +436,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
408
436
|
JSON.stringify({
|
|
409
437
|
version: 1,
|
|
410
438
|
values: {
|
|
411
|
-
|
|
439
|
+
"email-channel": true,
|
|
412
440
|
},
|
|
413
441
|
}),
|
|
414
442
|
);
|
|
@@ -427,7 +455,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
427
455
|
// Both old and new values should be persisted
|
|
428
456
|
clearFeatureFlagStoreCache();
|
|
429
457
|
const persisted = readPersistedFeatureFlags();
|
|
430
|
-
expect(persisted["
|
|
458
|
+
expect(persisted["email-channel"]).toBe(true);
|
|
431
459
|
expect(persisted["browser"]).toBe(true);
|
|
432
460
|
});
|
|
433
461
|
|
|
@@ -543,7 +571,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
543
571
|
test("accepts valid declared kebab-case key formats", async () => {
|
|
544
572
|
const handler = createFeatureFlagsPatchHandler();
|
|
545
573
|
|
|
546
|
-
const validKeys = ["browser", "
|
|
574
|
+
const validKeys = ["browser", "email-channel"];
|
|
547
575
|
|
|
548
576
|
for (const key of validKeys) {
|
|
549
577
|
clearFeatureFlagStoreCache();
|
|
@@ -614,7 +642,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
614
642
|
featureFlagStorePath,
|
|
615
643
|
JSON.stringify({
|
|
616
644
|
version: 1,
|
|
617
|
-
values: {
|
|
645
|
+
values: { "email-channel": true },
|
|
618
646
|
}),
|
|
619
647
|
);
|
|
620
648
|
clearFeatureFlagStoreCache();
|
|
@@ -633,7 +661,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
633
661
|
const raw = readFileSync(featureFlagStorePath, "utf-8");
|
|
634
662
|
const data = JSON.parse(raw);
|
|
635
663
|
expect(data.version).toBe(1);
|
|
636
|
-
expect(data.values["
|
|
664
|
+
expect(data.values["email-channel"]).toBe(true);
|
|
637
665
|
expect(data.values["browser"]).toBe(false);
|
|
638
666
|
|
|
639
667
|
// Verify no temp files left behind
|
|
@@ -647,7 +675,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
647
675
|
const handler = createFeatureFlagsPatchHandler();
|
|
648
676
|
|
|
649
677
|
// Fire multiple concurrent PATCH requests at the same time
|
|
650
|
-
const flagKeys = ["browser", "
|
|
678
|
+
const flagKeys = ["browser", "email-channel"];
|
|
651
679
|
|
|
652
680
|
const results = await Promise.all(
|
|
653
681
|
flagKeys.map((key) =>
|
|
@@ -69,8 +69,17 @@ function defaultCredentials(): Record<string, string> {
|
|
|
69
69
|
// ---------------------------------------------------------------------------
|
|
70
70
|
// Setup / teardown
|
|
71
71
|
// ---------------------------------------------------------------------------
|
|
72
|
+
const savedVellumPlatformUrl = process.env.VELLUM_PLATFORM_URL;
|
|
73
|
+
const savedPlatformAssistantId = process.env.PLATFORM_ASSISTANT_ID;
|
|
74
|
+
const savedPlatformInternalApiKey = process.env.PLATFORM_INTERNAL_API_KEY;
|
|
75
|
+
|
|
72
76
|
beforeEach(() => {
|
|
73
77
|
process.env.BASE_DATA_DIR = testDir;
|
|
78
|
+
// Clear env vars that the production code falls back to, so tests remain
|
|
79
|
+
// deterministic unless they explicitly set them.
|
|
80
|
+
delete process.env.VELLUM_PLATFORM_URL;
|
|
81
|
+
delete process.env.PLATFORM_ASSISTANT_ID;
|
|
82
|
+
delete process.env.PLATFORM_INTERNAL_API_KEY;
|
|
74
83
|
mkdirSync(protectedDir, { recursive: true });
|
|
75
84
|
clearRemoteFeatureFlagStoreCache();
|
|
76
85
|
fetchMock = mock(async () => new Response());
|
|
@@ -82,6 +91,17 @@ afterEach(() => {
|
|
|
82
91
|
} else {
|
|
83
92
|
process.env.BASE_DATA_DIR = savedBaseDataDir;
|
|
84
93
|
}
|
|
94
|
+
// Restore env vars
|
|
95
|
+
const restoreEnv = (key: string, saved: string | undefined): void => {
|
|
96
|
+
if (saved === undefined) {
|
|
97
|
+
delete process.env[key];
|
|
98
|
+
} else {
|
|
99
|
+
process.env[key] = saved;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
restoreEnv("VELLUM_PLATFORM_URL", savedVellumPlatformUrl);
|
|
103
|
+
restoreEnv("PLATFORM_ASSISTANT_ID", savedPlatformAssistantId);
|
|
104
|
+
restoreEnv("PLATFORM_INTERNAL_API_KEY", savedPlatformInternalApiKey);
|
|
85
105
|
try {
|
|
86
106
|
rmSync(testDir, { recursive: true, force: true });
|
|
87
107
|
} catch {
|
|
@@ -94,9 +114,12 @@ afterEach(() => {
|
|
|
94
114
|
// Tests
|
|
95
115
|
// ---------------------------------------------------------------------------
|
|
96
116
|
describe("RemoteFeatureFlagSync", () => {
|
|
97
|
-
test("skips sync when
|
|
117
|
+
test("skips sync when no platform URL is available from cache or env", async () => {
|
|
118
|
+
fetchMock = mock(async () => Response.json({ flags: { ff1: true } }));
|
|
119
|
+
|
|
98
120
|
const creds = defaultCredentials();
|
|
99
121
|
delete creds["credential/vellum/platform_base_url"];
|
|
122
|
+
delete process.env.VELLUM_PLATFORM_URL;
|
|
100
123
|
|
|
101
124
|
const sync = new RemoteFeatureFlagSync({
|
|
102
125
|
credentials: fakeCredentialCache(creds),
|
|
@@ -104,10 +127,31 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
104
127
|
await sync.start();
|
|
105
128
|
sync.stop();
|
|
106
129
|
|
|
130
|
+
// No fetch calls — sync is skipped when platform URL is unavailable
|
|
107
131
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
108
132
|
});
|
|
109
133
|
|
|
110
|
-
test("
|
|
134
|
+
test("falls back to VELLUM_PLATFORM_URL env var when platform_base_url is missing", async () => {
|
|
135
|
+
fetchMock = mock(async () => Response.json({ flags: {} }));
|
|
136
|
+
process.env.VELLUM_PLATFORM_URL = "https://env-platform.example.com";
|
|
137
|
+
|
|
138
|
+
const creds = defaultCredentials();
|
|
139
|
+
delete creds["credential/vellum/platform_base_url"];
|
|
140
|
+
|
|
141
|
+
const sync = new RemoteFeatureFlagSync({
|
|
142
|
+
credentials: fakeCredentialCache(creds),
|
|
143
|
+
});
|
|
144
|
+
await sync.start();
|
|
145
|
+
sync.stop();
|
|
146
|
+
|
|
147
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
148
|
+
const [url] = fetchMock.mock.calls[0];
|
|
149
|
+
expect(url).toBe(
|
|
150
|
+
"https://env-platform.example.com/v1/feature-flags/assistant-flag-values/",
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("skips sync when assistant_api_key is missing and no PLATFORM_INTERNAL_API_KEY", async () => {
|
|
111
155
|
const creds = defaultCredentials();
|
|
112
156
|
delete creds["credential/vellum/assistant_api_key"];
|
|
113
157
|
|
|
@@ -120,7 +164,25 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
120
164
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
121
165
|
});
|
|
122
166
|
|
|
123
|
-
test("
|
|
167
|
+
test("does not use PLATFORM_INTERNAL_API_KEY when assistant_api_key is missing", async () => {
|
|
168
|
+
fetchMock = mock(async () => Response.json({ flags: {} }));
|
|
169
|
+
process.env.PLATFORM_INTERNAL_API_KEY = "internal-key-123";
|
|
170
|
+
|
|
171
|
+
const creds = defaultCredentials();
|
|
172
|
+
delete creds["credential/vellum/assistant_api_key"];
|
|
173
|
+
|
|
174
|
+
const sync = new RemoteFeatureFlagSync({
|
|
175
|
+
credentials: fakeCredentialCache(creds),
|
|
176
|
+
});
|
|
177
|
+
await sync.start();
|
|
178
|
+
sync.stop();
|
|
179
|
+
|
|
180
|
+
// PLATFORM_INTERNAL_API_KEY is only for internal gateway endpoints —
|
|
181
|
+
// feature flag sync requires assistant_api_key (Api-Key auth).
|
|
182
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("skips sync when platform_assistant_id is missing and no PLATFORM_ASSISTANT_ID", async () => {
|
|
124
186
|
const creds = defaultCredentials();
|
|
125
187
|
delete creds["credential/vellum/platform_assistant_id"];
|
|
126
188
|
|
|
@@ -133,6 +195,24 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
133
195
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
134
196
|
});
|
|
135
197
|
|
|
198
|
+
test("falls back to PLATFORM_ASSISTANT_ID env var when credential cache is empty", async () => {
|
|
199
|
+
fetchMock = mock(async () => Response.json({ flags: {} }));
|
|
200
|
+
process.env.PLATFORM_ASSISTANT_ID = "env-asst-456";
|
|
201
|
+
|
|
202
|
+
const creds = defaultCredentials();
|
|
203
|
+
delete creds["credential/vellum/platform_assistant_id"];
|
|
204
|
+
|
|
205
|
+
const sync = new RemoteFeatureFlagSync({
|
|
206
|
+
credentials: fakeCredentialCache(creds),
|
|
207
|
+
});
|
|
208
|
+
await sync.start();
|
|
209
|
+
sync.stop();
|
|
210
|
+
|
|
211
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
212
|
+
const [url] = fetchMock.mock.calls[0];
|
|
213
|
+
expect(url).toContain("/v1/feature-flags/assistant-flag-values/");
|
|
214
|
+
});
|
|
215
|
+
|
|
136
216
|
test("fetches and caches flags on successful response", async () => {
|
|
137
217
|
fetchMock = mock(async () =>
|
|
138
218
|
Response.json({
|
|
@@ -265,7 +345,7 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
265
345
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
266
346
|
const [url] = fetchMock.mock.calls[0];
|
|
267
347
|
expect(url).toBe(
|
|
268
|
-
"https://platform.example.com/v1/
|
|
348
|
+
"https://platform.example.com/v1/feature-flags/assistant-flag-values/",
|
|
269
349
|
);
|
|
270
350
|
});
|
|
271
351
|
|
|
@@ -344,7 +424,7 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
344
424
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
345
425
|
const [url] = fetchMock.mock.calls[0];
|
|
346
426
|
expect(url).toBe(
|
|
347
|
-
"https://platform.example.com/v1/
|
|
427
|
+
"https://platform.example.com/v1/feature-flags/assistant-flag-values/",
|
|
348
428
|
);
|
|
349
429
|
});
|
|
350
430
|
|
|
@@ -365,7 +445,7 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
365
445
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
366
446
|
const [url, init] = fetchMock.mock.calls[0];
|
|
367
447
|
expect(url).toBe(
|
|
368
|
-
"https://platform.example.com/v1/
|
|
448
|
+
"https://platform.example.com/v1/feature-flags/assistant-flag-values/",
|
|
369
449
|
);
|
|
370
450
|
const headers = init?.headers as Record<string, string>;
|
|
371
451
|
expect(headers.Authorization).toBe("Api-Key trimmed-key");
|
|
@@ -326,6 +326,32 @@ describe("normalizeSlackMessageEdit", () => {
|
|
|
326
326
|
expect(result!.threadTs).toBe("1700000000.000100");
|
|
327
327
|
});
|
|
328
328
|
|
|
329
|
+
test("DM edit without thread_ts omits threadTs", () => {
|
|
330
|
+
const config = makeConfig();
|
|
331
|
+
const event = makeMessageChangedEvent({ channel_type: "im" });
|
|
332
|
+
const result = normalizeSlackMessageEdit(event, "evt-dm-edit-1", config);
|
|
333
|
+
|
|
334
|
+
expect(result).not.toBeNull();
|
|
335
|
+
expect(result!.threadTs).toBeUndefined();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("DM edit with thread_ts preserves threadTs", () => {
|
|
339
|
+
const config = makeConfig();
|
|
340
|
+
const event = makeMessageChangedEvent({
|
|
341
|
+
channel_type: "im",
|
|
342
|
+
message: {
|
|
343
|
+
user: "U_USER123",
|
|
344
|
+
text: "edited hello world",
|
|
345
|
+
ts: "1700000000.000100",
|
|
346
|
+
thread_ts: "1700000000.000050",
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
const result = normalizeSlackMessageEdit(event, "evt-dm-edit-2", config);
|
|
350
|
+
|
|
351
|
+
expect(result).not.toBeNull();
|
|
352
|
+
expect(result!.threadTs).toBe("1700000000.000050");
|
|
353
|
+
});
|
|
354
|
+
|
|
329
355
|
test("DM edits use default assistant when channel is not in routing table", () => {
|
|
330
356
|
const config = makeConfig({
|
|
331
357
|
unmappedPolicy: "reject",
|