@vellumai/assistant 0.12.4-staging.2 → 0.12.4-staging.4

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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
  3. package/src/__tests__/pricing.test.ts +33 -0
  4. package/src/__tests__/skill-load-tool.test.ts +42 -11
  5. package/src/__tests__/skill-tool-factory.test.ts +50 -2
  6. package/src/__tests__/validate-input.test.ts +73 -0
  7. package/src/cli/commands/__tests__/plugins.test.ts +29 -1
  8. package/src/cli/commands/plugins.ts +16 -0
  9. package/src/cli/lib/__tests__/inspect-plugin.test.ts +29 -0
  10. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +32 -2
  11. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +15 -0
  12. package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +21 -0
  13. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +65 -0
  14. package/src/cli/lib/__tests__/plugin-details.test.ts +43 -0
  15. package/src/cli/lib/__tests__/plugins-install-offline.test.ts +26 -0
  16. package/src/cli/lib/inspect-plugin.ts +5 -0
  17. package/src/cli/lib/plugin-catalog-cache.ts +24 -2
  18. package/src/cli/lib/plugin-catalog-local.ts +9 -0
  19. package/src/cli/lib/plugin-catalog-visibility.ts +41 -0
  20. package/src/cli/lib/plugin-details.ts +10 -7
  21. package/src/cli/lib/search-plugins.ts +2 -0
  22. package/src/config/feature-flag-registry.json +9 -1
  23. package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
  24. package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
  25. package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
  26. package/src/providers/anthropic/client.ts +2 -2
  27. package/src/providers/model-catalog.ts +234 -0
  28. package/src/providers/openai/codex-models.ts +2 -0
  29. package/src/runtime/routes/__tests__/plugins-routes.test.ts +8 -0
  30. package/src/runtime/routes/plugins-routes.ts +5 -2
  31. package/src/skills/validate-input.ts +75 -11
  32. package/src/tools/skills/execute.ts +1 -1
  33. package/src/tools/skills/format-input-schema.ts +15 -0
  34. package/src/tools/skills/load.ts +2 -26
  35. package/src/tools/skills/skill-tool-factory.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.4-staging.2",
3
+ "version": "0.12.4-staging.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -446,7 +446,7 @@ describe("OpenAIResponsesProvider explicit prompt caching (GPT-5.6+)", () => {
446
446
  expect(JSON.stringify(messages)).not.toContain("prompt_cache_breakpoint");
447
447
  });
448
448
 
449
- test("catalog flags GPT-5.6 and GPT-6 Astra direct-openai rows", () => {
449
+ test("catalog flags GPT-5.6 and GPT-6 direct-openai rows", () => {
450
450
  const openai = PROVIDER_CATALOG.find((p) => p.id === "openai");
451
451
  const flagged = (openai?.models ?? [])
452
452
  .filter((m) => m.supportsPromptCacheBreakpoints)
@@ -457,6 +457,8 @@ describe("OpenAIResponsesProvider explicit prompt caching (GPT-5.6+)", () => {
457
457
  "gpt-5.6-sol",
458
458
  "gpt-5.6-terra",
459
459
  "gpt-6-astra",
460
+ "gpt-6-luna",
461
+ "gpt-6-sol",
460
462
  ]);
461
463
  });
462
464
  });
@@ -135,6 +135,39 @@ describe("resolvePricing", () => {
135
135
  expect(result.estimatedCostUsd).toBeCloseTo(0.5 + 0.625 + 0.05, 10);
136
136
  });
137
137
 
138
+ test("bills GPT-6 Sol at published short-context rates", () => {
139
+ const result = resolvePricingForUsage("openai", "gpt-6-sol", {
140
+ directInputTokens: 50_000,
141
+ outputTokens: 50_000,
142
+ cacheCreationInputTokens: 50_000,
143
+ cacheReadInputTokens: 50_000,
144
+ anthropicCacheCreation: null,
145
+ });
146
+
147
+ expect(result.pricingStatus).toBe("priced");
148
+ // 0.05M x $2 direct + 0.05M x $2.5 write + 0.05M x $0.2 read
149
+ // + 0.05M x $10 output.
150
+ expect(result.estimatedCostUsd).toBeCloseTo(0.1 + 0.125 + 0.01 + 0.5, 10);
151
+ });
152
+
153
+ test("bills GPT-6 Luna at the long-context tier above 272k", () => {
154
+ const result = resolvePricingForUsage("openai", "gpt-6-luna", {
155
+ directInputTokens: 500_000,
156
+ outputTokens: 1_000_000,
157
+ cacheCreationInputTokens: 500_000,
158
+ cacheReadInputTokens: 1_000_000,
159
+ anthropicCacheCreation: null,
160
+ });
161
+
162
+ expect(result.pricingStatus).toBe("priced");
163
+ // Tier rates: 0.5M x $0.2 direct + 0.5M x $0.25 write + 1M x $0.02 read
164
+ // + 1M x $0.75 output.
165
+ expect(result.estimatedCostUsd).toBeCloseTo(
166
+ 0.1 + 0.125 + 0.02 + 0.75,
167
+ 10,
168
+ );
169
+ });
170
+
138
171
  test("bills GPT-6 Astra at the long-context tier above 272k", () => {
139
172
  const result = resolvePricingForUsage("openai", "gpt-6-astra", {
140
173
  directInputTokens: 500_000,
@@ -1,4 +1,4 @@
1
- import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { beforeEach, describe, expect, mock, test } from "bun:test";
4
4
 
@@ -781,16 +781,48 @@ describe("skill_load tool", () => {
781
781
  expect(result.content).toContain("Deploy the application to production");
782
782
  expect(result.content).toContain("Rollback to previous version");
783
783
 
784
- // Should list parameters with types and required/optional markers
785
- expect(result.content).toContain(
786
- "- environment (string, required): Target environment",
784
+ const schemas = [
785
+ ...result.content.matchAll(/```json\n([\s\S]*?)\n```/g),
786
+ ].map((match) => JSON.parse(match[1]));
787
+ expect(schemas[0].required).toEqual(["environment"]);
788
+ expect(schemas[0].properties.environment).toEqual({
789
+ type: "string",
790
+ description: "Target environment",
791
+ });
792
+ expect(schemas[0].properties.force.type).toBe("boolean");
793
+ expect(schemas[1].required).toEqual(["version"]);
794
+ });
795
+
796
+ test("discloses the complete annotation mark schema before execution", async () => {
797
+ const manifest = JSON.parse(
798
+ readFileSync(
799
+ join(
800
+ import.meta.dir,
801
+ "../config/bundled-skills/screen-annotation/TOOLS.json",
802
+ ),
803
+ "utf-8",
804
+ ),
787
805
  );
788
- expect(result.content).toContain(
789
- "- force (boolean, optional): Force deploy even if checks fail",
806
+ writeSkill(
807
+ "annotation-schema",
808
+ "Annotation Schema",
809
+ "Point at a control",
810
+ "Name the control.",
790
811
  );
791
- expect(result.content).toContain(
792
- "- version (string, required): Version to rollback to",
812
+ writeToolsJson("annotation-schema", manifest.tools);
813
+
814
+ const result = await executeSkillLoad(
815
+ { skill: "annotation-schema" },
816
+ "macos",
793
817
  );
818
+ expect(result.isError).toBe(false);
819
+ const schemaMatch = result.content.match(/```json\n([\s\S]*?)\n```/);
820
+ expect(schemaMatch).not.toBeNull();
821
+ const schema = JSON.parse(schemaMatch![1]);
822
+ expect(schema).toEqual(manifest.tools[0].input_schema);
823
+ expect(schema.properties.marks.items.oneOf).toHaveLength(2);
824
+ expect(schema.properties.marks.items.properties.target.minLength).toBe(1);
825
+ expect(schema.properties.marks.items.properties.caption.maxLength).toBe(80);
794
826
  });
795
827
 
796
828
  test("skill without TOOLS.json does not include tool schemas section", async () => {
@@ -862,9 +894,8 @@ describe("skill_load tool", () => {
862
894
  // The child skill's tool schemas should appear (#### level under ### Tools from …)
863
895
  expect(result.content).toContain("#### child_action");
864
896
  expect(result.content).toContain("A child tool action");
865
- expect(result.content).toContain(
866
- "- target (string, required): Action target",
867
- );
897
+ const schemaMatch = result.content.match(/```json\n([\s\S]*?)\n```/);
898
+ expect(JSON.parse(schemaMatch![1]).required).toEqual(["target"]);
868
899
  expect(result.content).toContain(
869
900
  "Use `skill_execute` to call these tools.",
870
901
  );
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
6
 
7
- import type { SkillToolEntry } from "../config/skills.js";
7
+ import { getBundledSkillsDir, type SkillToolEntry } from "../config/skills.js";
8
8
  import { RiskLevel } from "../permissions/types.js";
9
9
  import { computeSkillVersionHash } from "../skills/version-hash.js";
10
10
  import { resolveSubagentRole } from "../subagent/role-resolution.js";
@@ -261,6 +261,54 @@ describe("createSkillTool — unknown parameter validation", () => {
261
261
  expect(result.content).toContain('Unknown parameter "bar"');
262
262
  });
263
263
 
264
+ test.each([
265
+ { description: "Filters" },
266
+ { marks: [{ description: "Filters" }] },
267
+ { marks: [{}] },
268
+ { marks: [{ target: 42 }] },
269
+ { marks: [{ x: 0.1, y: 0.2 }] },
270
+ { marks: [{ target: "Filters", x: 0.1, y: 0.2, width: 0.3, height: 0.4 }] },
271
+ ])(
272
+ "an annotation validation error provides enough schema for one corrected retry: %j",
273
+ async (invalid) => {
274
+ const skillDir = join(getBundledSkillsDir(), "screen-annotation");
275
+ const manifest = JSON.parse(
276
+ readFileSync(join(skillDir, "TOOLS.json"), "utf-8"),
277
+ );
278
+ const entry = manifest.tools.find(
279
+ (tool: SkillToolEntry) => tool.name === "screen_point_at",
280
+ );
281
+ const tool = createSkillTool(
282
+ entry,
283
+ skillDir,
284
+ computeSkillVersionHash(skillDir),
285
+ BUNDLED,
286
+ );
287
+ const calls: unknown[] = [];
288
+ const context = makeContext({
289
+ proxyToolResolver: async (name, input) => {
290
+ calls.push({ name, input });
291
+ return { content: "Drew 1 mark", isError: false };
292
+ },
293
+ });
294
+
295
+ const rejected = await tool.execute(invalid, context);
296
+ expect(rejected.isError).toBe(true);
297
+ expect(calls).toHaveLength(0);
298
+ expect(rejected.content).toContain("skill_load");
299
+ const schemaMatch = rejected.content.match(/```json\n([\s\S]*?)\n```/);
300
+ expect(schemaMatch).not.toBeNull();
301
+ expect(JSON.parse(schemaMatch![1])).toEqual(entry.input_schema);
302
+
303
+ const input = {
304
+ marks: [{ target: "Filters", caption: "Click Filters" }],
305
+ };
306
+ const corrected = await tool.execute(input, context);
307
+ expect(corrected.isError).toBe(false);
308
+ expect(calls).toEqual([{ name: "computer_use_point_at", input }]);
309
+ },
310
+ );
311
+
264
312
  test("allows input with only known parameters", async () => {
265
313
  const hash = computeSkillVersionHash(tempDir);
266
314
  const tool = createSkillTool(
@@ -471,7 +519,7 @@ describe("createSkillTool — required/type/enum validation", () => {
471
519
 
472
520
  expect(result.isError).toBe(true);
473
521
  expect(result.content).toContain(
474
- "auto_open must be a boolean — pass true or false as a JSON boolean, not a string",
522
+ "auto_open must be a boolean: pass true or false as a JSON boolean, not a string",
475
523
  );
476
524
  });
477
525
 
@@ -6,6 +6,79 @@ import {
6
6
  validateInputAgainstSchema,
7
7
  } from "../skills/validate-input.js";
8
8
 
9
+ describe("nested input validation", () => {
10
+ const schema = {
11
+ properties: {
12
+ options: {
13
+ type: "object",
14
+ properties: { label: { type: "string" } },
15
+ required: ["label"],
16
+ },
17
+ },
18
+ };
19
+
20
+ test("reports nested required fields and types with their path", () => {
21
+ expect(
22
+ validateInputAgainstSchema("example", { options: {} }, schema),
23
+ ).toEqual({
24
+ ok: false,
25
+ errors: ["options.label is required"],
26
+ });
27
+ expect(
28
+ validateInputAgainstSchema("example", { options: { label: 42 } }, schema),
29
+ ).toEqual({
30
+ ok: false,
31
+ errors: ["options.label must be a string"],
32
+ });
33
+ });
34
+
35
+ test("preserves open nested objects and only rejects explicitly closed extra fields", () => {
36
+ const input = { options: { label: "Example", extra: true } };
37
+ expect(validateInputAgainstSchema("example", input, schema)).toEqual({
38
+ ok: true,
39
+ });
40
+ expect(
41
+ validateInputAgainstSchema("example", input, {
42
+ properties: {
43
+ options: {
44
+ ...schema.properties.options,
45
+ additionalProperties: false,
46
+ },
47
+ },
48
+ }),
49
+ ).toEqual({
50
+ ok: false,
51
+ errors: ['Unknown parameter "options.extra". Supported: "label"'],
52
+ });
53
+ });
54
+
55
+ test("does not treat a richer oneOf as a presence-only union", () => {
56
+ expect(
57
+ validateInputAgainstSchema(
58
+ "example",
59
+ { options: { label: "Example" } },
60
+ {
61
+ properties: {
62
+ options: {
63
+ ...schema.properties.options,
64
+ oneOf: [
65
+ {
66
+ required: ["label"],
67
+ properties: { label: { const: "Example" } },
68
+ },
69
+ {
70
+ required: ["label"],
71
+ properties: { label: { const: "Other" } },
72
+ },
73
+ ],
74
+ },
75
+ },
76
+ },
77
+ ),
78
+ ).toEqual({ ok: true });
79
+ });
80
+ });
81
+
9
82
  // ---------------------------------------------------------------------------
10
83
  // required
11
84
  // ---------------------------------------------------------------------------
@@ -66,6 +66,20 @@ let platformInstallCalls: Array<{ name: string; force?: boolean }> = [];
66
66
  let upgradePluginCalls: UpgradePluginOptions[] = [];
67
67
  let catalogMatches: PluginSearchMatch[] = [];
68
68
  let catalogError: Error | null = null;
69
+ const CATALOG_COMMIT = "a".repeat(40);
70
+
71
+ function githubCatalogMatch(name: string): PluginSearchMatch {
72
+ return {
73
+ name,
74
+ path: `github:example-org/${name}@${CATALOG_COMMIT}`,
75
+ category: null,
76
+ source: {
77
+ kind: "github",
78
+ repo: `example-org/${name}`,
79
+ ref: CATALOG_COMMIT,
80
+ },
81
+ };
82
+ }
69
83
 
70
84
  /**
71
85
  * Queued daemon IPC responses. The default (empty queue) is a transport
@@ -269,7 +283,10 @@ beforeEach(() => {
269
283
  installPluginCalls = [];
270
284
  platformInstallCalls = [];
271
285
  upgradePluginCalls = [];
272
- catalogMatches = [];
286
+ catalogMatches = [
287
+ githubCatalogMatch("example"),
288
+ githubCatalogMatch("imessage"),
289
+ ];
273
290
  catalogError = null;
274
291
  ipcResults = [];
275
292
  inspectResult = null;
@@ -485,6 +502,17 @@ describe("plugins install - declared-schedules consent", () => {
485
502
  ]);
486
503
  expect(r.exitCode).toBe(0);
487
504
  });
505
+
506
+ test("does not use the outage fallback for a gated integration", async () => {
507
+ catalogError = new Error("catalog unavailable");
508
+
509
+ const r = await runCommand(["plugins", "install", "gamma"]);
510
+
511
+ expect(installPluginCalls).toHaveLength(0);
512
+ expect(platformInstallCalls).toHaveLength(0);
513
+ expect(r.stderr).toContain("not in the marketplace catalog");
514
+ expect(r.exitCode).toBe(1);
515
+ });
488
516
  });
489
517
 
490
518
  describe("plugins upgrade - declared-schedules consent (local fallback)", () => {
@@ -238,7 +238,15 @@ export function registerPluginsCommand(program: Command): void {
238
238
  const platformEnabled =
239
239
  libs.catalogLocal.arePlatformFeaturesEnabled();
240
240
  if (platformEnabled) {
241
+ if (!libs.catalogLocal.isPluginCatalogEntryVisible(nameOrUrl)) {
242
+ console.error(
243
+ `Plugin "${nameOrUrl}" is not in the marketplace catalog.`,
244
+ );
245
+ process.exitCode = 1;
246
+ return;
247
+ }
241
248
  let match;
249
+ let catalogResolved = false;
242
250
  try {
243
251
  match = (
244
252
  await libs.catalogCache.getPluginCatalog(
@@ -246,10 +254,18 @@ export function registerPluginsCommand(program: Command): void {
246
254
  { fetch: globalThis.fetch.bind(globalThis) },
247
255
  )
248
256
  ).matches.find((candidate) => candidate.name === nameOrUrl);
257
+ catalogResolved = true;
249
258
  } catch {
250
259
  // The install endpoint remains authoritative and can succeed
251
260
  // independently when catalog discovery is unavailable.
252
261
  }
262
+ if (!match && catalogResolved) {
263
+ console.error(
264
+ `Plugin "${nameOrUrl}" is not in the marketplace catalog.`,
265
+ );
266
+ process.exitCode = 1;
267
+ return;
268
+ }
253
269
  if (match?.source.kind === "local") {
254
270
  result = await libs.installGitHub.installPlugin(
255
271
  {
@@ -306,6 +306,35 @@ describe("inspectPlugin", () => {
306
306
  expect(result.remote?.commit).toBe(SHA_B);
307
307
  });
308
308
 
309
+ test("hides an uninstalled gated integration without fetching the marketplace", async () => {
310
+ let fetchCalls = 0;
311
+ const fetch = (async () => {
312
+ fetchCalls += 1;
313
+ return new Response("unexpected request", { status: 500 });
314
+ }) as FetchLike;
315
+
316
+ await expect(
317
+ inspectPlugin(
318
+ { name: "gamma" },
319
+ { fetch, workspacePluginsDir: workspace },
320
+ ),
321
+ ).rejects.toBeInstanceOf(PluginInspectNotFoundError);
322
+ expect(fetchCalls).toBe(0);
323
+ });
324
+
325
+ test("keeps an installed gated integration inspectable", async () => {
326
+ installPlugin(workspace, "gamma", { sidecar: null });
327
+ const fetch = makeFetch({ marketplace: manifestWith("gamma", SHA_B) });
328
+
329
+ const result = await inspectPlugin(
330
+ { name: "gamma" },
331
+ { fetch, workspacePluginsDir: workspace },
332
+ );
333
+
334
+ expect(result.installed).toBe(true);
335
+ expect(result.local?.description).toBe("Installed copy.");
336
+ });
337
+
309
338
  test("previews an uninstalled bundled provider offline without fetching GitHub", async () => {
310
339
  const savedDisablePlatform = process.env.VELLUM_DISABLE_PLATFORM;
311
340
  const savedIsPlatform = process.env.IS_PLATFORM;
@@ -19,6 +19,7 @@ import {
19
19
  import type { FetchLike } from "../fetch-like.js";
20
20
  import {
21
21
  getPluginCatalog,
22
+ getPluginCatalogForInstalledMetadata,
22
23
  invalidatePluginCatalogCache,
23
24
  mergePlatformCatalogWithBundledLocals,
24
25
  PLUGIN_CATALOG_CACHE_TTL_MS,
@@ -102,7 +103,7 @@ describe("getPluginCatalog", () => {
102
103
 
103
104
  // THEN the platform is fetched exactly once — the second call is cached
104
105
  expect(calls()).toBe(1);
105
- expect(first).toBe(second);
106
+ expect(first).toEqual(second);
106
107
  expect(githubNames(first)).toEqual(["a"]);
107
108
  expect(first.ref).toBe("main");
108
109
  });
@@ -161,7 +162,10 @@ describe("getPluginCatalog", () => {
161
162
  delete process.env.IS_PLATFORM;
162
163
 
163
164
  const { fetch, calls } = platformFetch(["ignored"]);
164
- const deps: SearchPluginsDeps = { fetch };
165
+ const deps: SearchPluginsDeps = {
166
+ fetch,
167
+ featureFlagEnabled: () => true,
168
+ };
165
169
 
166
170
  // WHEN we request the catalog
167
171
  const result = await getPluginCatalog("main", deps);
@@ -173,6 +177,32 @@ describe("getPluginCatalog", () => {
173
177
  // The requested ref is echoed onto the wire contract.
174
178
  expect(result.ref).toBe("main");
175
179
  });
180
+
181
+ test("applies feature-flag visibility to bundled catalog entries", async () => {
182
+ process.env.VELLUM_DISABLE_PLATFORM = "true";
183
+ delete process.env.IS_PLATFORM;
184
+
185
+ const { fetch } = platformFetch(["ignored"]);
186
+ const result = await getPluginCatalog("main", {
187
+ fetch,
188
+ featureFlagEnabled: () => false,
189
+ });
190
+
191
+ expect(result.matches.map((match) => match.name)).not.toContain("gamma");
192
+ expect(result.matches.map((match) => match.name)).toContain("fathom");
193
+ });
194
+
195
+ test("preserves hidden entries for installed-plugin metadata", async () => {
196
+ process.env.VELLUM_DISABLE_PLATFORM = "true";
197
+ delete process.env.IS_PLATFORM;
198
+
199
+ const { fetch } = platformFetch(["ignored"]);
200
+ const result = await getPluginCatalogForInstalledMetadata("main", {
201
+ fetch,
202
+ });
203
+
204
+ expect(result.matches.map((match) => match.name)).toContain("gamma");
205
+ });
176
206
  });
177
207
 
178
208
  describe("mergePlatformCatalogWithBundledLocals", () => {
@@ -13,6 +13,7 @@ import bundledManifest from "../bundled-marketplace.json" with { type: "json" };
13
13
  import {
14
14
  buildBundledPluginCatalog,
15
15
  readBundledPluginCatalog,
16
+ resolveBundledPluginSource,
16
17
  } from "../plugin-catalog-local.js";
17
18
  import { marketplaceManifestSchema } from "../plugin-marketplace.js";
18
19
  import { marketplaceMatch } from "../search-plugins.js";
@@ -96,3 +97,17 @@ describe("readBundledPluginCatalog", () => {
96
97
  expect(readBundledPluginCatalog()).toBe(readBundledPluginCatalog());
97
98
  });
98
99
  });
100
+
101
+ describe("resolveBundledPluginSource", () => {
102
+ test("hides a QA integration while its feature flag is off", () => {
103
+ expect(resolveBundledPluginSource("gamma", () => false)).toBeNull();
104
+ });
105
+
106
+ test("resolves a QA integration while its feature flag is on", () => {
107
+ expect(resolveBundledPluginSource("gamma", () => true)).toEqual({
108
+ kind: "local",
109
+ path: "plugins/mcp-catalog/gamma",
110
+ version: "1.0.2",
111
+ });
112
+ });
113
+ });
@@ -195,6 +195,27 @@ describe("catalog-backed resolvers (bundled, offline)", () => {
195
195
  await resolvePluginSourceFromCatalog("no-such-plugin", rejectingDeps),
196
196
  ).toBeNull();
197
197
  });
198
+
199
+ test("does not resolve a QA integration while its feature flag is off", async () => {
200
+ expect(
201
+ await resolvePluginSourceFromCatalog("gamma", {
202
+ ...rejectingDeps,
203
+ featureFlagEnabled: () => false,
204
+ }),
205
+ ).toBeNull();
206
+ });
207
+
208
+ test("resolves a QA integration while its feature flag is on", async () => {
209
+ expect(
210
+ await resolvePluginSourceFromCatalog("gamma", {
211
+ ...rejectingDeps,
212
+ featureFlagEnabled: () => true,
213
+ }),
214
+ ).toMatchObject({
215
+ kind: "local",
216
+ path: "plugins/mcp-catalog/gamma",
217
+ });
218
+ });
198
219
  });
199
220
 
200
221
  describe("catalog-backed resolvers (platform enabled)", () => {
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ filterPluginCatalogByFeatureFlags,
5
+ MCP_CATALOG_QA_INTEGRATIONS_FLAG,
6
+ } from "../plugin-catalog-visibility.js";
7
+ import type { PluginCatalog, PluginSearchMatch } from "../search-plugins.js";
8
+
9
+ function match(name: string): PluginSearchMatch {
10
+ return {
11
+ name,
12
+ path: `local:plugins/mcp-catalog/${name}@1.0.0`,
13
+ category: null,
14
+ source: {
15
+ kind: "local",
16
+ path: `plugins/mcp-catalog/${name}`,
17
+ version: "1.0.0",
18
+ },
19
+ };
20
+ }
21
+
22
+ function catalog(names: string[]): PluginCatalog {
23
+ return { ref: "main", matches: names.map(match) };
24
+ }
25
+
26
+ describe("filterPluginCatalogByFeatureFlags", () => {
27
+ test("hides QA integrations while the flag is off", () => {
28
+ const input = catalog([
29
+ "fathom",
30
+ "gamma",
31
+ "guru",
32
+ "intercom",
33
+ "ramp",
34
+ "semrush",
35
+ "typeform",
36
+ "notion",
37
+ ]);
38
+
39
+ const result = filterPluginCatalogByFeatureFlags(input, (key) => {
40
+ expect(key).toBe(MCP_CATALOG_QA_INTEGRATIONS_FLAG);
41
+ return false;
42
+ });
43
+
44
+ expect(result.matches.map((entry) => entry.name)).toEqual([
45
+ "fathom",
46
+ "notion",
47
+ ]);
48
+ });
49
+
50
+ test("returns the full catalog while the flag is on", () => {
51
+ const input = catalog([
52
+ "fathom",
53
+ "gamma",
54
+ "guru",
55
+ "intercom",
56
+ "ramp",
57
+ "semrush",
58
+ "typeform",
59
+ ]);
60
+
61
+ const result = filterPluginCatalogByFeatureFlags(input, () => true);
62
+
63
+ expect(result).toBe(input);
64
+ });
65
+ });
@@ -513,6 +513,49 @@ describe("getPluginDetails (bundled catalog, offline)", () => {
513
513
  expect(details.ref).toBe(historicalRef);
514
514
  });
515
515
 
516
+ test("hides a gated integration at an explicit historical ref", async () => {
517
+ const historicalRef = "a".repeat(40);
518
+ let fetchCalls = 0;
519
+ const fetch = (async () => {
520
+ fetchCalls += 1;
521
+ return new Response("unexpected request", { status: 500 });
522
+ }) as FetchLike;
523
+
524
+ await expect(
525
+ getPluginDetails(
526
+ { name: "gamma", ref: historicalRef },
527
+ { fetch, workspacePluginsDir: workspace },
528
+ ),
529
+ ).rejects.toBeInstanceOf(PluginDetailsNotFoundError);
530
+ expect(fetchCalls).toBe(0);
531
+ });
532
+
533
+ test("keeps an installed gated integration visible at an explicit historical ref", async () => {
534
+ const target = join(workspace, "gamma");
535
+ mkdirSync(target, { recursive: true });
536
+ writeFileSync(join(target, "README.md"), "# Installed Gamma");
537
+ writeFileSync(
538
+ join(target, "package.json"),
539
+ JSON.stringify({ version: "1.0.0", description: "local gamma" }),
540
+ );
541
+ let fetchCalls = 0;
542
+ const fetch = (async () => {
543
+ fetchCalls += 1;
544
+ return new Response("unexpected request", { status: 500 });
545
+ }) as FetchLike;
546
+
547
+ const details = await getPluginDetails(
548
+ { name: "gamma", ref: "a".repeat(40) },
549
+ { fetch, workspacePluginsDir: workspace },
550
+ );
551
+
552
+ expect(details.installed).toBe(true);
553
+ expect(details.readme).toBe("# Installed Gamma");
554
+ expect(details.description).toBe("local gamma");
555
+ expect(details.source).toBeNull();
556
+ expect(fetchCalls).toBe(0);
557
+ });
558
+
516
559
  test("degrades gracefully when the historical marketplace fetch fails", async () => {
517
560
  // GIVEN an installed copy and a historical ref whose marketplace fetch errors
518
561
  const historicalRef = "a".repeat(40);