From 78219c96afa90e5f711a05ed9d7374520709ca47 Mon Sep 17 00:00:00 2001 From: Paul <8560030+pwang347@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:35:32 -0700 Subject: [PATCH 1/4] chat: Use AgentFinder feed for featured customizations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/base/common/product.ts | 1 + .../common/agentFinderRestProvider.ts | 113 ++++++++++++++- .../common/agentFinderRestProvider.test.ts | 63 ++++++++ .../common/customizationMarketplaceService.ts | 10 ++ src/vs/sessions/AI_CUSTOMIZATIONS.md | 2 + .../aiCustomizationDiscoveryPage.ts | 58 +++++++- ...ustomizationMarketplaceWorkbenchService.ts | 83 ++++++++++- .../aiCustomizationDiscoveryPage.test.ts | 66 ++++++++- ...izationMarketplaceWorkbenchService.test.ts | 134 +++++++++++++++++- ...aiCustomizationManagementEditor.fixture.ts | 34 +++++ 10 files changed, 548 insertions(+), 16 deletions(-) diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 0767bb78c5affd..8f7dde35eaac64 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -456,6 +456,7 @@ export interface IDefaultChatAgent { readonly tokenEntitlementUrl: string; readonly mcpRegistryDataUrl: string; readonly mcpConnectorsUrl?: string; + readonly agentFinderFeaturedFeedId?: string; readonly managedSettingsUrl: string; readonly chatQuotaExceededContext: string; diff --git a/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts b/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts index 6a43da003ed5a6..66281f1c6b4446 100644 --- a/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts +++ b/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts @@ -19,10 +19,15 @@ import { IMcpGalleryService } from '../../mcp/common/mcpManagement.js'; import { IRequestService, readBoundedResponse } from '../../request/common/request.js'; const endpoint = 'https://agentfinder.github.com/api/v1'; +const feedEndpoint = 'https://agentfinder.github.com/internal/v1/feeds'; const requestTimeout = 30_000; const maxResponseBytes = 5 * 1024 * 1024; const defaultPageSize = 30; const maxPageSize = 100; +const maxFeedEntries = 100; +const maxFeedIdLength = 4096; +const maxFeedNameCodePoints = 512; +const maxFeedIdentifierLength = 512; const maxQueryLength = 4096; const maxUnsupportedOnlyPages = 32; const maxUriLength = 8192; @@ -39,6 +44,11 @@ const mcpIconRequestTimeout = 5_000; class AgentFinderError extends Error { } +export interface IAgentFinderResourceFeed { + readonly name: string; + readonly items: readonly ICustomizationMarketplaceEntry[]; +} + export class AgentFinderRestProvider implements ICustomizationMarketplaceProvider { readonly id = CustomizationMarketplaceSources.AgentFinderPublicFeed.id; @@ -88,7 +98,7 @@ export class AgentFinderRestProvider implements ICustomizationMarketplaceProvide followRedirects: 0, callSite: 'agentFinder.query', }; - const response = await raceCancellationError(this.requestPage(request, cancellation.token), cancellation.token); + const response = await raceCancellationError(this.requestJson(request, cancellation.token, 'catalog'), cancellation.token); const page = parsePage(response, pageSize, query ? { kind: 'search', pageToken } : { kind: 'browse', offset }); const items = page.items.filter(item => item.mediaType !== CustomizationMarketplaceMediaType.CursorPlugin); if (items.length || !page.nextCursor) { @@ -121,6 +131,56 @@ export class AgentFinderRestProvider implements ICustomizationMarketplaceProvide } } + async getFeed(feedId: string, authorization: string, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + throw new CancellationError(); + } + if (!feedId || feedId.length > maxFeedIdLength || !feedId.trim() || !authorization) { + throw new AgentFinderError(localize('agentFinder.invalidFeaturedFeed', "The featured customization feed is not configured correctly.")); + } + + const store = new DisposableStore(); + const cancellation = store.add(new CancellationTokenSource(token)); + let timedOut = false; + const requestTimeoutDisposable = disposableTimeout(() => { + timedOut = true; + cancellation.cancel(); + }, requestTimeout, store); + + try { + const response = await raceCancellationError(this.requestJson({ + url: `${feedEndpoint}/${encodeURIComponent(feedId)}`, + type: 'GET', + headers: { Accept: 'application/json', Authorization: `Bearer ${authorization}` }, + disableCache: true, + timeout: requestTimeout, + followRedirects: 0, + callSite: 'agentFinder.featured', + }, cancellation.token, 'featured'), cancellation.token); + const feed = parseFeed(response, feedId); + requestTimeoutDisposable.dispose(); + const page = await this.resolveMcpIcons({ items: feed.items }, token); + return { + name: feed.name, + items: page.items.filter(item => item.mediaType !== CustomizationMarketplaceMediaType.CursorPlugin), + }; + } catch (error) { + if (token.isCancellationRequested) { + throw new CancellationError(); + } + if (timedOut) { + throw new AgentFinderError(localize('agentFinder.featuredTimeout', "The featured customizations took too long to load. Try again.")); + } + if (error instanceof AgentFinderError || isCancellationError(error)) { + throw error; + } + throw new AgentFinderError(localize('agentFinder.featuredUnavailable', "Unable to reach the featured customization feed. Check your connection and try again.")); + } finally { + cancellation.cancel(); + store.dispose(); + } + } + private async resolveMcpIcons(page: ICustomizationMarketplaceSourcePage, token: CancellationToken): Promise { if (!page.items.some(item => !item.icon && item.installation?.kind === 'mcp' && item.externalUrl)) { return page; @@ -175,24 +235,35 @@ export class AgentFinderRestProvider implements ICustomizationMarketplaceProvide } } - private async requestPage(options: IRequestOptions, token: CancellationToken): Promise { + private async requestJson(options: IRequestOptions, token: CancellationToken, operation: 'catalog' | 'featured'): Promise { const context = await this.requestService.request(options, token); try { if (token.isCancellationRequested) { throw new CancellationError(); } const status = context.res.statusCode; + if (operation === 'featured' && status === 401) { + throw new AgentFinderError(localize('agentFinder.featuredSignInRequired', "Sign in to view featured customizations.")); + } if (status === 429) { - throw new AgentFinderError(localize('agentFinder.rateLimited', "The customization catalog is receiving too many requests. Try again later.")); + throw new AgentFinderError(operation === 'catalog' + ? localize('agentFinder.rateLimited', "The customization catalog is receiving too many requests. Try again later.") + : localize('agentFinder.featuredRateLimited', "The featured customization feed is receiving too many requests. Try again later.")); } if (!status || status < 200 || status >= 300) { - throw new AgentFinderError(localize('agentFinder.httpError', "The customization catalog could not complete the request (HTTP {0}). Try again later.", status ?? '—')); + throw new AgentFinderError(operation === 'catalog' + ? localize('agentFinder.httpError', "The customization catalog could not complete the request (HTTP {0}). Try again later.", status ?? '—') + : localize('agentFinder.featuredHttpError', "The featured customization feed could not complete the request (HTTP {0}). Try again later.", status ?? '—')); } - const text = await raceCancellationError(readBoundedResponse(context, maxResponseBytes, () => new AgentFinderError(localize('agentFinder.responseTooLarge', "The customization catalog response is too large. Try a smaller page."))), token); + const text = await raceCancellationError(readBoundedResponse(context, maxResponseBytes, () => new AgentFinderError(operation === 'catalog' + ? localize('agentFinder.responseTooLarge', "The customization catalog response is too large. Try a smaller page.") + : localize('agentFinder.featuredResponseTooLarge', "The featured customization feed response is too large."))), token); try { return JSON.parse(text); } catch { - throw new AgentFinderError(localize('agentFinder.invalidJson', "The customization catalog returned invalid JSON. Try again later.")); + throw new AgentFinderError(operation === 'catalog' + ? localize('agentFinder.invalidJson', "The customization catalog returned invalid JSON. Try again later.") + : localize('agentFinder.featuredInvalidJson', "The featured customization feed returned invalid JSON. Try again later.")); } } finally { context.stream.destroy(); @@ -283,6 +354,36 @@ function parsePage(value: unknown, pageSize: number, cursor: { kind: 'browse'; o }; } +function parseFeed(value: unknown, feedId: string): IAgentFinderResourceFeed { + if (!isRecord(value) || value.id !== feedId || typeof value.name !== 'string' || !value.name.trim() || + [...value.name].length > maxFeedNameCodePoints || !isNonNegativeInteger(value.version) || value.version < 1 || + !Array.isArray(value.entries) || value.entries.length > maxFeedEntries) { + throw invalidResponse(); + } + const identifiers = new Set(); + const items: ICustomizationMarketplaceEntry[] = []; + for (const entry of value.entries) { + if (!isRecord(entry) || typeof entry.identifier !== 'string' || !entry.identifier || + entry.identifier.length > maxFeedIdentifierLength || identifiers.has(entry.identifier) || + (entry.status !== 'available' && entry.status !== 'unavailable')) { + throw invalidResponse(); + } + identifiers.add(entry.identifier); + if (entry.status === 'unavailable') { + if (entry.resource !== undefined) { + throw invalidResponse(); + } + continue; + } + const resource = parseResource(entry.resource); + if (resource.identifier !== entry.identifier) { + throw invalidResponse(); + } + items.push(resource); + } + return { name: value.name, items }; +} + function parseResource(value: unknown): ICustomizationMarketplaceEntry { if (!isRecord(value)) { throw invalidResponse(); diff --git a/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts b/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts index 14d332a44df421..3627a5ddc5d6da 100644 --- a/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts +++ b/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts @@ -159,6 +159,69 @@ suite('AgentFinderRestProvider', () => { }); }); + test('retrieves an authenticated saved feed in order and omits unavailable resources', async () => { + const { service, requests } = createService({ + id: 'feed/featured', + name: 'Featured customizations', + version: 7, + entries: [ + { identifier: skill.identifier, status: 'available', resource: skill }, + { identifier: 'urn:air:example.com:skills:removed', status: 'unavailable' }, + { + identifier: 'urn:air:github.com:github:awesome-copilot:plugins:accessibility-kanban', + status: 'available', + resource: { + ...skill, + identifier: 'urn:air:github.com:github:awesome-copilot:plugins:accessibility-kanban', + displayName: 'Accessibility Kanban', + type: CustomizationMarketplaceMediaType.CopilotPlugin, + mediaType: CustomizationMarketplaceMediaType.CopilotPlugin, + url: 'https://github.com/github/awesome-copilot/blob/main/plugins/accessibility-kanban/plugin.json', + metadata: { sourceSet: 'github/awesome-copilot', repoPath: 'plugins/accessibility-kanban/plugin.json' }, + }, + }, + ], + }); + + const feed = await service.getFeed('feed/featured', 'featured-token', CancellationToken.None); + + assert.deepStrictEqual({ + name: feed.name, + items: feed.items.map(item => [item.identifier, item.displayName, item.installation]), + requests: requests.requests, + }, { + name: 'Featured customizations', + items: [ + [skill.identifier, skill.displayName, { kind: 'skill', repository: 'ChromeDevTools/chrome-devtools-mcp', ref: 'main', path: 'skills/a11y-debugging' }], + ['urn:air:github.com:github:awesome-copilot:plugins:accessibility-kanban', 'Accessibility Kanban', { kind: 'plugin', repository: 'github/awesome-copilot', ref: 'main', path: 'plugins/accessibility-kanban' }], + ], + requests: [{ + url: 'https://agentfinder.github.com/internal/v1/feeds/feed%2Ffeatured', + type: 'GET', + headers: { Accept: 'application/json', Authorization: 'Bearer featured-token' }, + disableCache: true, + timeout: 30_000, + followRedirects: 0, + callSite: 'agentFinder.featured', + }], + }); + }); + + test('rejects malformed saved feeds and reports authentication failures', async () => { + const invalidFeeds = [ + { id: 'different', name: 'Featured', version: 1, entries: [] }, + { id: 'feed', name: 'Featured', version: 1, entries: [{ identifier: skill.identifier, status: 'available', resource: { ...skill, identifier: 'different' } }] }, + { id: 'feed', name: 'Featured', version: 1, entries: [{ identifier: skill.identifier, status: 'unavailable', resource: skill }] }, + ]; + for (const body of invalidFeeds) { + const { service } = createService(body); + await assert.rejects(service.getFeed('feed', 'token', CancellationToken.None), /invalid response/); + } + + const { service } = createService({ error: 'Unauthorized' }, 401); + await assert.rejects(service.getFeed('feed', 'token', CancellationToken.None), /Sign in to view featured customizations/); + }); + test('resolves an MCP registry icon through the fixed Agent Finder registry', async () => { const lookups: { url: string; manifestUrl: string | undefined; manifestVersion: string | undefined; cancelled: boolean }[] = []; const mcpGalleryService = upcastPartial({ diff --git a/src/vs/platform/customizationMarketplace/common/customizationMarketplaceService.ts b/src/vs/platform/customizationMarketplace/common/customizationMarketplaceService.ts index 900f1920f536ed..a804b700718ddd 100644 --- a/src/vs/platform/customizationMarketplace/common/customizationMarketplaceService.ts +++ b/src/vs/platform/customizationMarketplace/common/customizationMarketplaceService.ts @@ -129,6 +129,12 @@ export interface ICustomizationMarketplacePage { readonly sourceErrors?: readonly ICustomizationMarketplaceSourceError[]; } +export interface ICustomizationMarketplaceFeatured { + readonly sourceId: string; + readonly items: readonly ICustomizationMarketplaceResource[]; + readonly error?: string; +} + export interface ICustomizationMarketplaceSourceError { readonly sourceId: string; readonly message: string; @@ -209,7 +215,11 @@ export interface ICustomizationMarketplaceService { readonly allSources?: readonly ICustomizationMarketplaceSourceInfo[]; /** Fires when non-configuration inputs change source availability or query identity. */ readonly onDidChangeSources?: Event; + /** Sources that can provide an ordered browse-page collection. */ + readonly featuredSourceIds?: readonly string[]; query(options: ICustomizationMarketplaceQuery, token: CancellationToken): Promise; + /** Optional ordered collection for the browse page. Failures are returned on the collection so regular catalog results remain available. */ + getFeatured?(options: Pick, token: CancellationToken): Promise; /** Optional renderer-owned recovery; not part of the catalog transport. */ getSourceRecoveryAction?(sourceId: string): ICustomizationMarketplaceSourceRecoveryAction | undefined; } diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index b2828121488954..be32a6129bcd48 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -106,6 +106,8 @@ The home page shows the original Overview until the default-off marketplace visi Marketplace composition supplies `ICustomizationMarketplaceProvider`s with unique provider IDs and user-visible source IDs. Multiple internal providers can belong to one contributed source; selecting that source queries all of its applicable providers while results and errors retain the contributed source ID. Per-source enablement and the query service remain independent for legacy management lists; only Discover presentation and Marketplace installation apply the separate visibility setting on top of enabled sources. The requesting window selects enabled source IDs and forwards them with each query; native desktop routes only the GitHub Feed through shared-process IPC and queries the MCP gallery in the renderer. Only selected providers are instantiated and queried. Each provider owns transport, response validation, metadata normalization, and validation of any installation provenance. Resource identity includes the source ID, opaque identifier, and version, including for deduplication and pending installation state. Optional resource icons accept either one theme-independent URI or a `{ light, dark }` URI bundle; consumers resolve bundles against the active VS Code theme. Installation records preserve the v1 scalar `icon` field as the light/default value and add an optional `iconDark` field, so existing scalar records and older readers remain compatible. Invalid persisted icon fields are sanitized and logged independently; presentation metadata never invalidates an otherwise valid installation record. +Marketplace services may advertise source IDs with an ordered featured collection. When the product supplies an AgentFinder featured-feed ID, Discover retrieves that collection with the current default GitHub account, preserves its live resource order, and keeps ordinary browse results available when the featured request fails. Products without a configured feed retain the local browse-order fallback and perform no featured-feed authentication or request. + A successful managed install creates an independently persisted, machine-local profile installation record containing the resource identity, its validated installation descriptor, and its exact local target or account-scoped connector identity. Independent storage keys prevent concurrent workbench windows from overwriting unrelated records. The install service exposes one observable immutable snapshot indexed by catalog identity and exact target; Skills, MCP, Discover, and management actions use its lookup helpers instead of rebuilding record indexes. Skill records also retain their harness/destination identity and relative package files; skill and repository-backed Plugin records retain the immutable Git revision that supplied their content. Configured Plugin records retain their opaque marketplace identity and exact installed URI. Connector records preserve that an account previously connected the resource when the current catalog no longer returns it, while the remote connection remains authoritative for current state. Local discovery never creates this association: same-name or same-repository local items remain independent until an explicit marketplace install succeeds. Marketplace page size bounds the combined result list, not each source's contribution. Search sources return a descending sequence of optional 0–100 relevance scores across their native pages; a score is an adapter-assigned ranking signal, not a trust or quality rating. The service merges those sequences by score, treating absent scores as zero. Adapter-assigned entry priority breaks equal relevance scores and orders queryless browsing into tiers: custom entries precede defaults, and entries at the same tier interleave round-robin. Neither priority nor relevance is displayed as a quality or trust rating. The service preserves each source's native order and browse rotation across page boundaries. It queries sources concurrently, backfills short pages, preserves stable native fetch sizes, and buffers undisplayed entries without re-querying exhausted sources. diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts index 50ed3dccdd0f48..9cc9d6addeb3f4 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts @@ -14,7 +14,7 @@ import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/s import { Action, IAction, Separator, SubmenuAction } from '../../../../../base/common/actions.js'; import { equals } from '../../../../../base/common/arrays.js'; import { RunOnceScheduler } from '../../../../../base/common/async.js'; -import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { structuralEquals } from '../../../../../base/common/equals.js'; import { getErrorMessage, isCancellationError, onUnexpectedError } from '../../../../../base/common/errors.js'; @@ -27,7 +27,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { CustomizationMarketplaceMediaType, getCustomizationMarketplaceResourceKey, ICustomizationMarketplaceCursor, ICustomizationMarketplaceResource, ICustomizationMarketplaceService, ICustomizationMarketplaceSourceError, ICustomizationMarketplaceSourceInfo } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; +import { CustomizationMarketplaceMediaType, getCustomizationMarketplaceResourceKey, ICustomizationMarketplaceCursor, ICustomizationMarketplaceFeatured, ICustomizationMarketplaceResource, ICustomizationMarketplaceService, ICustomizationMarketplaceSourceError, ICustomizationMarketplaceSourceInfo } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; import { affectsCustomizationMarketplaceSources, getVisibleCustomizationMarketplaceSources } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; @@ -135,8 +135,14 @@ interface ICatalogPageState { interface IBrowseCatalogCache { readonly items: readonly ICustomizationMarketplaceResource[]; readonly page: ICatalogPageState; + readonly featuredItems: readonly ICustomizationMarketplaceResource[] | undefined; + readonly featuredSourceError: ICustomizationMarketplaceSourceError | undefined; } +type FeaturedLoadOutcome = + | { readonly kind: 'success'; readonly featured: ICustomizationMarketplaceFeatured | undefined } + | { readonly kind: 'error'; readonly error: Error }; + interface IDiscoveryRowTemplate { readonly root: HTMLElement; readonly primaryAction: HTMLButtonElement; @@ -591,6 +597,8 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto private installedItems: readonly IInstalledDiscoveryItem[] = []; private providerPlugins: readonly IInstalledDiscoveryItem[] = []; private catalogItems: readonly ICustomizationMarketplaceResource[] = []; + private featuredItems: readonly ICustomizationMarketplaceResource[] | undefined; + private featuredSourceError: ICustomizationMarketplaceSourceError | undefined; private marketplaceSources: readonly ICustomizationMarketplaceSourceInfo[] = []; private selectedSourceId: string | undefined; private visibleSectionIds = new Set(); @@ -987,6 +995,8 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto this.installErrors.clear(); const cached = this.query.isEmpty() ? this.browseCatalogCache.get(this.selectedSourceId ?? '') : undefined; this.catalogPage = cached?.page; + this.featuredItems = cached?.featuredItems; + this.featuredSourceError = cached?.featuredSourceError; if (cached) { this.catalogItems = cached.items; this.loaded = true; @@ -1065,6 +1075,8 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto this.catalogPage = undefined; this.browseCatalogCache.clear(); this.catalogItems = []; + this.featuredItems = undefined; + this.featuredSourceError = undefined; this.loaded = false; this.errorMessage = undefined; this.render(); @@ -1258,6 +1270,10 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto this.filteredBackfillLimitReached = false; this.errorMessage = undefined; this.render(true); + const featuredPromise = !append && this.query.isEmpty() && this.marketplaceService.featuredSourceIds?.some(sourceId => + (!this.selectedSourceId || sourceId === this.selectedSourceId) && this.marketplaceSources.some(source => source.id === sourceId)) + ? this.loadFeatured(request.token) + : undefined; try { const backfill = !this.query.isEmpty() && this.query.types.size > 0 && getCatalogMediaType(this.query.types) === undefined; @@ -1302,14 +1318,29 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto this.filteredBackfillLimitReached = true; } } + if (featuredPromise) { + const outcome = await featuredPromise; + if (outcome.kind === 'error') { + throw outcome.error; + } + if (sequence !== this.requestSequence || request.token.isCancellationRequested) { + return; + } + const featured = outcome.featured; + this.featuredItems = featured?.items; + this.featuredSourceError = featured?.error ? { sourceId: featured.sourceId, message: featured.error } : undefined; + } this.loaded = true; if (this.query.isEmpty() && this.catalogPage) { this.browseCatalogCache.set(this.selectedSourceId ?? '', { items: this.catalogItems, page: this.catalogPage, + featuredItems: this.featuredItems, + featuredSourceError: this.featuredSourceError, }); } } catch (error) { + request.cancel(); if (sequence !== this.requestSequence || isCancellationError(error)) { return; } @@ -1325,6 +1356,19 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto } } + private async loadFeatured(token: CancellationToken): Promise { + try { + return { + kind: 'success', + featured: await this.marketplaceService.getFeatured?.({ + sourceIds: this.selectedSourceId ? [this.selectedSourceId] : undefined, + }, token), + }; + } catch (error) { + return { kind: 'error', error: error instanceof Error ? error : new Error(getErrorMessage(error)) }; + } + } + private isLoadingMore(): boolean { return this.loading && this.loadingMore && this.hasMatchingCatalogItems(); } @@ -1342,7 +1386,11 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto } private render(preserveResults = false): void { - this.sourceWarnings.update(this.catalogPage?.sourceErrors ?? [], this.loading); + const sourceErrors = [...this.catalogPage?.sourceErrors ?? []]; + if (this.featuredSourceError && !sourceErrors.some(error => error.sourceId === this.featuredSourceError?.sourceId)) { + sourceErrors.push(this.featuredSourceError); + } + this.sourceWarnings.update(sourceErrors, this.loading); const catalogPending = this.isCatalogPending(); const showProgress = this.visible && catalogPending && !this.isLoadingMore(); if (showProgress !== this.progressBarActive) { @@ -1516,7 +1564,9 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto return; } this.browseStatus.textContent = ''; - const leading = getLeadingBrowseItems(this.catalogItems); + const leading = (this.featuredItems ?? getLeadingBrowseItems(this.catalogItems)) + .filter(item => getCatalogType(item) !== undefined) + .slice(0, leadingBrowseItemCount); const leadingIds = new Set(leading.map(getCustomizationMarketplaceResourceKey)); if (leading.length) { this.renderBrowseSection( diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMarketplaceWorkbenchService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMarketplaceWorkbenchService.ts index d39cf1ad3bb041..7886da362b1fb6 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMarketplaceWorkbenchService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMarketplaceWorkbenchService.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { raceCancellationError } from '../../../../../base/common/async.js'; +import { CancellationError, getErrorMessage, isCancellationError } from '../../../../../base/common/errors.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; import { Lazy } from '../../../../../base/common/lazy.js'; @@ -10,11 +12,13 @@ import { localize } from '../../../../../nls.js'; import { AgentFinderRestProvider } from '../../../../../platform/agentFinder/common/agentFinderRestProvider.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IPlatformCustomizationMarketplaceService } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceIpc.js'; -import { createLazyCustomizationMarketplaceProvider, CustomizationMarketplaceService, ICustomizationMarketplacePage, ICustomizationMarketplaceQuery, ICustomizationMarketplaceService, ICustomizationMarketplaceSourceRecoveryAction } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; -import { CustomizationMarketplaceConfiguration, CustomizationMarketplaceSources, queryEnabledCustomizationMarketplaceSources } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; +import { createLazyCustomizationMarketplaceProvider, CustomizationMarketplaceService, ICustomizationMarketplaceFeatured, ICustomizationMarketplacePage, ICustomizationMarketplaceQuery, ICustomizationMarketplaceService, ICustomizationMarketplaceSourceRecoveryAction } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; +import { CustomizationMarketplaceConfiguration, CustomizationMarketplaceSources, getEnabledCustomizationMarketplaceSources, queryEnabledCustomizationMarketplaceSources } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { createMcpGalleryMarketplaceProviders, getAllMcpGalleryMarketplaceSourceInfos, getCustomizationMarketplaceSourceInfos } from '../../../../../platform/customizationMarketplace/common/mcpGalleryMarketplaceProvider.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IAuthenticationService } from '../../../../services/authentication/common/authentication.js'; import { IPluginMarketplaceService } from '../../common/plugins/pluginMarketplaceService.js'; import { createPluginCustomizationMarketplaceProviders, getAllPluginCustomizationMarketplaceSourceInfos, getPluginCustomizationMarketplaceSourceInfos } from './pluginCustomizationMarketplaceProvider.js'; import { CopilotConnectorsMarketplaceProvider, ICopilotConnectorsService } from './copilotConnectorsService.js'; @@ -48,6 +52,7 @@ export class CustomizationMarketplaceWorkbenchService implements ICustomizationM declare readonly _serviceBrand: undefined; readonly allSources: ICustomizationMarketplaceService['sources']; readonly onDidChangeSources: Event; + readonly featuredSourceIds: readonly string[]; get sources() { return [ ...getPluginCustomizationMarketplaceSourceInfos(this.configurationService, this.pluginMarketplaceService), @@ -56,12 +61,16 @@ export class CustomizationMarketplaceWorkbenchService implements ICustomizationM ]; } private readonly service: Lazy; + private readonly agentFinderProvider: Lazy; constructor( @IConfigurationService private readonly configurationService: IConfigurationService, @IPlatformCustomizationMarketplaceService private readonly platformService: ICustomizationMarketplaceService, @IPluginMarketplaceService private readonly pluginMarketplaceService: IPluginMarketplaceService, @ICopilotConnectorsService private readonly copilotConnectorsService: ICopilotConnectorsService, + @IAuthenticationService private readonly authenticationService: IAuthenticationService, + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IProductService private readonly productService: IProductService, @IInstantiationService instantiationService: IInstantiationService, ) { this.allSources = [ @@ -69,9 +78,21 @@ export class CustomizationMarketplaceWorkbenchService implements ICustomizationM ...(platformService.allSources ?? platformService.sources), CustomizationMarketplaceSources.CopilotConnectors, ]; + this.featuredSourceIds = productService.defaultChatAgent?.agentFinderFeaturedFeedId + ? [CustomizationMarketplaceSources.AgentFinderPublicFeed.id] + : []; + const featuredAccountChanges = this.featuredSourceIds.length ? Event.any( + Event.map(defaultAccountService.onDidChangeDefaultAccount, () => undefined), + Event.map(Event.filter(authenticationService.onDidChangeSessions, event => { + const account = defaultAccountService.currentDefaultAccount; + return !!account && event.providerId === account.authenticationProvider.id && + [...event.event.added ?? [], ...event.event.changed ?? [], ...event.event.removed ?? []].some(session => session.id === account.sessionId); + }), () => undefined), + ) : Event.None; this.onDidChangeSources = Event.any( pluginMarketplaceService.onDidChangeMarketplaces, platformService.onDidChangeSources ?? Event.None, + featuredAccountChanges, ); const platformProviders = (platformService.allSources ?? platformService.sources).map(source => { const providerId = `platform.${source.id}`; @@ -96,9 +117,24 @@ export class CustomizationMarketplaceWorkbenchService implements ICustomizationM ...platformProviders, createLazyCustomizationMarketplaceProvider(CustomizationMarketplaceSources.CopilotConnectors.id, () => new CopilotConnectorsMarketplaceProvider(copilotConnectorsService, configurationService)), ])); + this.agentFinderProvider = new Lazy(() => instantiationService.createInstance(AgentFinderRestProvider)); } getSourceRecoveryAction(sourceId: string): ICustomizationMarketplaceSourceRecoveryAction | undefined { + if (sourceId === CustomizationMarketplaceSources.AgentFinderPublicFeed.id && + this.productService.defaultChatAgent?.agentFinderFeaturedFeedId && + !this.defaultAccountService.currentDefaultAccount) { + return { + label: localize('customizationMarketplace.signIn', "Sign In"), + kind: 'signIn', + run: async token => { + const account = await raceCancellationError(this.defaultAccountService.signIn(), token); + if (!account) { + throw new CancellationError(); + } + }, + }; + } if (sourceId !== CustomizationMarketplaceSources.CopilotConnectors.id || this.configurationService.getValue(CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled) !== true || !this.copilotConnectorsService.authorizationRequired) { @@ -115,6 +151,49 @@ export class CustomizationMarketplaceWorkbenchService implements ICustomizationM }; } + async getFeatured(options: Pick, token: CancellationToken): Promise { + const source = CustomizationMarketplaceSources.AgentFinderPublicFeed; + const feedId = this.productService.defaultChatAgent?.agentFinderFeaturedFeedId; + if (!feedId || !getEnabledCustomizationMarketplaceSources(this.configurationService, this.sources).some(candidate => candidate.id === source.id) || + (options.sourceIds && !options.sourceIds.includes(source.id))) { + return undefined; + } + if (token.isCancellationRequested) { + throw new CancellationError(); + } + try { + const authorization = await this.getAgentFinderAuthorization(token); + const feed = await this.agentFinderProvider.value.getFeed(feedId, authorization, token); + return { + sourceId: source.id, + items: feed.items.map(item => ({ ...item, sourceId: source.id })), + }; + } catch (error) { + if (token.isCancellationRequested || isCancellationError(error)) { + throw new CancellationError(); + } + return { sourceId: source.id, items: [], error: getErrorMessage(error) }; + } + } + + private async getAgentFinderAuthorization(token: CancellationToken): Promise { + const account = this.defaultAccountService.currentDefaultAccount ?? await raceCancellationError(this.defaultAccountService.getDefaultAccount(), token); + if (!account) { + throw new Error(localize('customizationMarketplace.agentFinderSignInRequired', "Sign in to view featured customizations.")); + } + const sessions = await raceCancellationError(this.authenticationService.getSessions(account.authenticationProvider.id, [], { silent: true }, true), token); + const currentAccount = this.defaultAccountService.currentDefaultAccount; + if (!currentAccount || currentAccount.authenticationProvider.id !== account.authenticationProvider.id || + currentAccount.sessionId !== account.sessionId || currentAccount.accountName !== account.accountName) { + throw new CancellationError(); + } + const session = sessions.find(candidate => candidate.id === account.sessionId); + if (!session) { + throw new Error(localize('customizationMarketplace.agentFinderSignInRequired', "Sign in to view featured customizations.")); + } + return session.accessToken; + } + query(options: ICustomizationMarketplaceQuery, token: CancellationToken): Promise { return queryEnabledCustomizationMarketplaceSources( this.configurationService, this.sources, options, token, diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDiscoveryPage.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDiscoveryPage.test.ts index b408289930fd45..b292dbfe8a3631 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDiscoveryPage.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDiscoveryPage.test.ts @@ -20,7 +20,7 @@ import { ICommandService } from '../../../../../../platform/commands/common/comm import { IConfigurationChangeEvent } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; -import { CustomizationMarketplaceMediaType, CustomizationMarketplaceService, getCustomizationMarketplaceResourceKey, ICustomizationMarketplacePage, ICustomizationMarketplaceQuery, ICustomizationMarketplaceResource, ICustomizationMarketplaceService, ICustomizationMarketplaceSourceRecoveryAction } from '../../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; +import { CustomizationMarketplaceMediaType, CustomizationMarketplaceService, getCustomizationMarketplaceResourceKey, ICustomizationMarketplaceFeatured, ICustomizationMarketplacePage, ICustomizationMarketplaceQuery, ICustomizationMarketplaceResource, ICustomizationMarketplaceService, ICustomizationMarketplaceSourceRecoveryAction } from '../../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; import { CustomizationMarketplaceConfiguration, CustomizationMarketplaceSources } from '../../../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; import { IListService, ListService, WorkbenchList } from '../../../../../../platform/list/browser/listService.js'; import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; @@ -102,6 +102,8 @@ suite('AICustomizationDiscoveryPage', () => { } }()); const requests: { options: ICustomizationMarketplaceQuery; token: CancellationToken; result: DeferredPromise }[] = []; + const featuredRequests: { options: Pick; token: CancellationToken }[] = []; + let featured: ICustomizationMarketplaceFeatured | undefined; const installs: { identifier: string; result: DeferredPromise }[] = []; const marketplaceChanges = store.add(new Emitter()); const recoveryActions = new Map(); @@ -124,7 +126,12 @@ suite('AICustomizationDiscoveryPage', () => { override readonly sources = sources; override readonly allSources = sources; override readonly onDidChangeSources = marketplaceChanges.event; + override get featuredSourceIds() { return featured ? [featured.sourceId] : []; } override getSourceRecoveryAction(sourceId: string) { return recoveryActions.get(sourceId); } + override async getFeatured(options: Pick, token: CancellationToken) { + featuredRequests.push({ options, token }); + return featured; + } override query(options: ICustomizationMarketplaceQuery, token: CancellationToken) { const result = new DeferredPromise(); requests.push({ options, token, result }); @@ -224,7 +231,8 @@ suite('AICustomizationDiscoveryPage', () => { return sourceMenu.getActions(); } return { - page, container, configuration, requests, marketplaceChanges, entitlement, sentimentChanged, recoveryActions, notifications, getSourceActions, listService, creationEvents, opened, openedDetails, openedInstalled, deletions, installs, repairs, cancellations, + page, container, configuration, requests, featuredRequests, marketplaceChanges, entitlement, sentimentChanged, recoveryActions, notifications, getSourceActions, listService, creationEvents, opened, openedDetails, openedInstalled, deletions, installs, repairs, cancellations, + setFeatured: (value: ICustomizationMarketplaceFeatured | undefined) => { featured = value; }, setInstallState: (resource: ICustomizationMarketplaceResource, state: CustomizationMarketplaceInstallState) => { const key = getCustomizationMarketplaceResourceKey(resource); installStates.set(key, state); @@ -397,6 +405,60 @@ suite('AICustomizationDiscoveryPage', () => { }); }); + test('browse uses the saved featured collection order independently of the catalog page', async () => { + const fixture = createPage(['agentFinder']); + fixture.setFeatured({ + sourceId: 'agentFinder', + items: [ + resource('feed-only', { mediaType: CustomizationMarketplaceMediaType.Skill }), + resource('third-party-two'), + resource('microsoft-skill', { publisher: 'Microsoft', mediaType: CustomizationMarketplaceMediaType.Skill }), + ], + }); + fixture.page.setVisible(true); + await fixture.requests[0].result.complete({ + items: [ + resource('azure-mcp', { installation: { kind: 'mcp', name: 'com.microsoft/azure', version: '1.0.0' } }), + resource('third-party-one'), + resource('third-party-two'), + resource('microsoft-skill', { publisher: 'Microsoft', mediaType: CustomizationMarketplaceMediaType.Skill }), + ], + }); + await timeout(0); + + assert.deepStrictEqual({ + featuredRequest: fixture.featuredRequests.map(request => request.options), + featured: Array.from(fixture.container.querySelectorAll('.customization-discovery-section.featured .customization-discovery-card-name')).map(element => element.textContent), + sections: Array.from(fixture.container.querySelectorAll('.customization-discovery-section:not(.featured) .customization-discovery-card-name')).map(element => element.textContent), + }, { + featuredRequest: [{ sourceIds: undefined }], + featured: ['feed-only', 'third-party-two', 'microsoft-skill'], + sections: ['azure-mcp', 'third-party-one'], + }); + }); + + test('a saved featured collection failure preserves regular browse results', async () => { + const fixture = createPage(['agentFinder']); + fixture.setFeatured({ + sourceId: 'agentFinder', + items: [], + error: 'Featured customizations are temporarily unavailable.', + }); + fixture.page.setVisible(true); + await fixture.requests[0].result.complete({ items: [resource('regular-skill', { mediaType: CustomizationMarketplaceMediaType.Skill })] }); + await timeout(0); + + assert.deepStrictEqual({ + featured: fixture.container.querySelector('.customization-discovery-section.featured'), + regular: fixture.page.getAccessibilityContent().includes('regular-skill'), + warning: fixture.container.querySelector('.customization-marketplace-source-warning')?.textContent, + }, { + featured: null, + regular: true, + warning: 'GitHub Feed: Featured customizations are temporarily unavailable.Retry', + }); + }); + test('browse card images replace fallback icons and restore them on error', async () => { const fixture = createPage(['agentFinder']); fixture.container.style.position = 'absolute'; diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMarketplaceWorkbenchService.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMarketplaceWorkbenchService.test.ts index 3b606725cac8b3..e722782b2f5d0d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMarketplaceWorkbenchService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMarketplaceWorkbenchService.test.ts @@ -11,6 +11,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../../ba import { isCancellationError } from '../../../../../../base/common/errors.js'; import { Event } from '../../../../../../base/common/event.js'; import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { IRequestOptions } from '../../../../../../base/parts/request/common/request.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -20,13 +21,17 @@ import { IConfigurationChangeEvent, IConfigurationService } from '../../../../.. import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { CustomizationMarketplaceConfiguration, CustomizationMarketplaceSources } from '../../../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; import { CustomizationMarketplaceMediaType, CustomizationMarketplaceService, ICustomizationMarketplaceCursor, ICustomizationMarketplaceEntry, ICustomizationMarketplacePage, ICustomizationMarketplaceQuery, ICustomizationMarketplaceRequest, ICustomizationMarketplaceService, ICustomizationMarketplaceSourceQuery } from '../../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; -import { IPluginMarketplacePage, IPluginMarketplaceQuery, IPluginMarketplaceService, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; -import { ChatConfiguration } from '../../../common/constants.js'; +import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IMcpGalleryService } from '../../../../../../platform/mcp/common/mcpManagement.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IRequestService } from '../../../../../../platform/request/common/request.js'; +import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; import { ICopilotConnector, ICopilotConnectorsService } from '../../../browser/aiCustomization/copilotConnectorsService.js'; import { CustomizationMarketplaceWorkbenchService, PlatformCustomizationMarketplaceWorkbenchService } from '../../../browser/aiCustomization/customizationMarketplaceWorkbenchService.js'; +import { ChatConfiguration } from '../../../common/constants.js'; +import { IPluginMarketplacePage, IPluginMarketplaceQuery, IPluginMarketplaceService, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; suite('CustomizationMarketplaceWorkbenchService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -68,6 +73,16 @@ suite('CustomizationMarketplaceWorkbenchService', () => { override readonly catalogMayRequireConsent = false; override readonly connectionStateKnown = false; }()); + instantiationService.stub(IAuthenticationService, new class extends mock() { + override readonly onDidChangeSessions = Event.None; + override async getSessions() { return []; } + }()); + instantiationService.stub(IDefaultAccountService, new class extends mock() { + override readonly onDidChangeDefaultAccount = Event.None; + override readonly currentDefaultAccount = null; + override async getDefaultAccount() { return null; } + }()); + instantiationService.stub(IProductService, new class extends mock() { }()); } function createService( @@ -92,6 +107,16 @@ suite('CustomizationMarketplaceWorkbenchService', () => { normalizedPlatformService, pluginMarketplaceService, connectorsService, + new class extends mock() { + override readonly onDidChangeSessions = Event.None; + override async getSessions() { return []; } + }(), + new class extends mock() { + override readonly onDidChangeDefaultAccount = Event.None; + override readonly currentDefaultAccount = null; + override async getDefaultAccount() { return null; } + }(), + new class extends mock() { }(), store.add(new TestInstantiationService()), ); } @@ -196,6 +221,111 @@ suite('CustomizationMarketplaceWorkbenchService', () => { }, { whileDisabled: { creations: 0, requests: 0 }, createdCatalogClient: true, creations: 1, requests: ['GET', 'POST'], sources: [['agentFinder'], ['agentFinder']] }); }); + test('loads the product-configured featured feed with the current default account', async () => { + const configuration = createConfiguration([CustomizationMarketplaceSources.AgentFinderPublicFeed.id]); + const requests: IRequestOptions[] = []; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IConfigurationService, configuration); + instantiationService.stub(IPlatformCustomizationMarketplaceService, new class extends mock() { + override readonly sources = [CustomizationMarketplaceSources.AgentFinderPublicFeed]; + override async query() { return { items: [] }; } + }()); + instantiationService.stub(IPluginMarketplaceService, new class extends mock() { + override readonly onDidChangeMarketplaces = Event.None; + override getMarketplaceReferences() { return []; } + }()); + registerConnectorService(instantiationService); + const account = { + authenticationProvider: { id: 'github', name: 'GitHub', enterprise: false }, + accountName: 'octocat', + sessionId: 'default-session', + enterprise: false, + }; + instantiationService.stub(IDefaultAccountService, new class extends mock() { + override readonly onDidChangeDefaultAccount = Event.None; + override readonly currentDefaultAccount = account; + override async getDefaultAccount() { return account; } + }()); + instantiationService.stub(IAuthenticationService, new class extends mock() { + override readonly onDidChangeSessions = Event.None; + override async getSessions() { + return [{ + id: 'default-session', + accessToken: 'default-account-token', + account: { id: 'account-id', label: 'octocat' }, + scopes: ['read:user'], + }]; + } + }()); + instantiationService.stub(IProductService, new class extends mock() { + override readonly defaultChatAgent = new class extends mock>() { + override readonly agentFinderFeaturedFeedId = 'feed/featured'; + }(); + }()); + instantiationService.stub(IRequestService, new class extends mock() { + override async request(options: IRequestOptions) { + requests.push(options); + return { + res: { statusCode: 200, headers: {} }, + stream: bufferToStream(VSBuffer.fromString(JSON.stringify({ + id: 'feed/featured', + name: 'Featured', + version: 1, + entries: [{ + identifier: 'urn:air:example.com:skills:review', + status: 'available', + resource: { + identifier: 'urn:air:example.com:skills:review', + displayName: 'Review', + type: CustomizationMarketplaceMediaType.Skill, + url: 'https://example.com/review', + }, + }], + }))), + }; + } + }()); + instantiationService.stub(IMcpGalleryService, new class extends mock() { }()); + instantiationService.stub(ILogService, new NullLogService()); + const service = instantiationService.createInstance(CustomizationMarketplaceWorkbenchService); + + const featured = await service.getFeatured({}, CancellationToken.None); + const excluded = await service.getFeatured({ sourceIds: [CustomizationMarketplaceSources.PluginMarketplaces.id] }, CancellationToken.None); + + assert.deepStrictEqual({ + featured, + excluded, + requests: requests.map(request => ({ url: request.url, authorization: request.headers?.Authorization })), + }, { + featured: { + sourceId: 'agentFinder', + items: [{ + sourceId: 'agentFinder', + identifier: 'urn:air:example.com:skills:review', + displayName: 'Review', + description: '', + mediaType: CustomizationMarketplaceMediaType.Skill, + tags: [], + capabilities: [], + representativeQueries: [], + url: URI.parse('https://example.com/review'), + externalUrl: 'https://example.com/review', + repository: undefined, + icon: undefined, + publisher: undefined, + version: undefined, + score: undefined, + installation: undefined, + }], + }, + excluded: undefined, + requests: [{ + url: 'https://agentfinder.github.com/internal/v1/feeds/feed%2Ffeatured', + authorization: 'Bearer default-account-token', + }], + }); + }); + test('plugin-only Discover does not query the public feed', async () => { const configuration = new TestConfigurationService(); store.add(configuration.onDidChangeConfigurationEmitter); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 5407c505cce7fe..f5a631ff20d4d0 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -992,6 +992,7 @@ interface IRenderEditorOptions { readonly copilotConnectorsEnabled?: boolean; readonly copilotConnectors?: readonly ICopilotConnector[]; readonly marketplaceVisibilityEnabled?: boolean; + readonly featuredFeedEnabled?: boolean; readonly otherSourceEnabled?: boolean; readonly toggleMarketplaceVisibility?: boolean; readonly customizationMarketplaceState?: 'ready' | 'empty' | 'error' | 'loading' | 'loadingMore'; @@ -1160,6 +1161,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor override readonly onDidChangeSentiment = Event.None; }()); reg.defineInstance(ICustomizationMarketplaceService, new class extends mock() { + override readonly featuredSourceIds = options.featuredFeedEnabled ? ['testSource'] : []; override readonly sources = [ CustomizationMarketplaceSources.PluginMarketplaces, CustomizationMarketplaceSources.McpGallery, @@ -1168,6 +1170,20 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor { id: 'additionalSource', displayName: 'Additional Feed', enablementSetting: 'test.marketplace.other.enabled', requiresMarketplaceVisibility: true }, CustomizationMarketplaceSources.CopilotConnectors, ]; + override async getFeatured(query: Pick) { + if (!options.featuredFeedEnabled || query.sourceIds && !query.sourceIds.includes('testSource')) { + return undefined; + } + return { + sourceId: 'testSource', + items: [ + customizationMarketplaceResources[2], + customizationMarketplaceResources[1], + customizationMarketplaceResources[4], + customizationMarketplaceResources[0], + ], + }; + } override async query(query: ICustomizationMarketplaceQuery): Promise { customizationMarketplaceQueryCount++; assert(sourceEnabled(), 'A fixture with no enabled sources must not query the catalog.'); @@ -1768,6 +1784,13 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor && featuredDescription.getBoundingClientRect().top > featuredName.getBoundingClientRect().top, 'Featured cards must place source metadata beside the name and the description on the next line.', ); + if (options.featuredFeedEnabled) { + assert( + [...featured?.querySelectorAll('.customization-discovery-card-name') ?? []].map(element => element.textContent).join('\n') + === ['Figma', 'Browser tools', 'Documentation workflow', 'Repository review'].join('\n'), + 'Discover must preserve the saved featured feed order.', + ); + } const header = ctx.container.querySelector('.customization-discovery-header'); const searchRow = ctx.container.querySelector('.customization-discovery-search-row'); const browse = ctx.container.querySelector('.customization-discovery-browse'); @@ -3325,6 +3348,17 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), }), + DiscoverFeaturedFeed: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + expectedVisualDescriptions: ['Discover renders the product-configured AgentFinder saved feed as the ordered elevated collection, while the remaining catalog items stay grouped by type below it.'], + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + marketplaceVisibilityEnabled: true, + featuredFeedEnabled: true, + width: 800, + }), + }), + DiscoverSearchResults: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: false }, expectedVisualDescriptions: ['Wide Discover search uses the same centered content measure as the management pages. The title, search control, result selection bounds, and state messages share horizontal edges; rows retain compact icons, two-line text hierarchy, ratings, and centered actions.'], From 98faaa22527c8573f69f15c3a2f814bbfaa175c1 Mon Sep 17 00:00:00 2001 From: Paul <8560030+pwang347@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:41:48 -0700 Subject: [PATCH 2/4] test: Show GitHub Feed in featured fixture Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...aiCustomizationManagementEditor.fixture.ts | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index f5a631ff20d4d0..230d9411763cb4 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -1040,7 +1040,9 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor const marketplaceVisibilityEnabled = options.marketplaceVisibilityEnabled ?? false; const discoverEnabled = marketplaceVisibilityEnabled; const marketplaceResources = [ - ...(agentFinderPublicFeedEnabled ? customizationMarketplaceResources : []), + ...(agentFinderPublicFeedEnabled ? customizationMarketplaceResources.map(resource => options.featuredFeedEnabled + ? { ...resource, sourceId: CustomizationMarketplaceSources.AgentFinderPublicFeed.id } + : resource) : []), ...(options.copilotConnectorsEnabled ? [copilotConnectorMarketplaceResource] : []), ]; const skillUIIntegrations = options.skillUIIntegrations ?? new Map(); @@ -1161,26 +1163,29 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor override readonly onDidChangeSentiment = Event.None; }()); reg.defineInstance(ICustomizationMarketplaceService, new class extends mock() { - override readonly featuredSourceIds = options.featuredFeedEnabled ? ['testSource'] : []; + override readonly featuredSourceIds = options.featuredFeedEnabled ? [CustomizationMarketplaceSources.AgentFinderPublicFeed.id] : []; override readonly sources = [ CustomizationMarketplaceSources.PluginMarketplaces, CustomizationMarketplaceSources.McpGallery, - { id: 'testSource', displayName: 'Marketplace 1', enablementSetting: CustomizationMarketplaceConfiguration.AgentFinderPublicFeedEnabled, requiresMarketplaceVisibility: true }, - { id: 'otherSource', displayName: 'Marketplace 2', enablementSetting: CustomizationMarketplaceConfiguration.AgentFinderPublicFeedEnabled, requiresMarketplaceVisibility: true }, + ...(options.featuredFeedEnabled ? [CustomizationMarketplaceSources.AgentFinderPublicFeed] : [ + { id: 'testSource', displayName: 'Marketplace 1', enablementSetting: CustomizationMarketplaceConfiguration.AgentFinderPublicFeedEnabled, requiresMarketplaceVisibility: true }, + { id: 'otherSource', displayName: 'Marketplace 2', enablementSetting: CustomizationMarketplaceConfiguration.AgentFinderPublicFeedEnabled, requiresMarketplaceVisibility: true }, + ]), { id: 'additionalSource', displayName: 'Additional Feed', enablementSetting: 'test.marketplace.other.enabled', requiresMarketplaceVisibility: true }, CustomizationMarketplaceSources.CopilotConnectors, ]; override async getFeatured(query: Pick) { - if (!options.featuredFeedEnabled || query.sourceIds && !query.sourceIds.includes('testSource')) { + const sourceId = this.featuredSourceIds[0]; + if (!sourceId || query.sourceIds && !query.sourceIds.includes(sourceId)) { return undefined; } return { - sourceId: 'testSource', + sourceId, items: [ - customizationMarketplaceResources[2], - customizationMarketplaceResources[1], - customizationMarketplaceResources[4], - customizationMarketplaceResources[0], + marketplaceResources[2], + marketplaceResources[1], + marketplaceResources[4], + marketplaceResources[0], ], }; } From 24241460616d806d0cdaec60394f26d23103b6dd Mon Sep 17 00:00:00 2001 From: Paul <8560030+pwang347@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:48:26 -0700 Subject: [PATCH 3/4] test: Add featured feed fixture icons Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aiCustomizationManagementEditor.fixture.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 230d9411763cb4..f4b9027103f112 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -869,6 +869,17 @@ const customizationMarketplaceResources: readonly ICustomizationMarketplaceResou }, ]; +function fixtureMarketplaceIcon(background: string, content: string): URI { + return URI.parse(`data:image/svg+xml,${encodeURIComponent(`${content}`)}`); +} + +const featuredMarketplaceIcons = new Map([ + ['example/figma-plugin', fixtureMarketplaceIcon('#8250df', '')], + ['example/browser-tools', fixtureMarketplaceIcon('#0969da', '')], + ['example/docs-workflow', fixtureMarketplaceIcon('#bf8700', '')], + ['example/repository-review', fixtureMarketplaceIcon('#1a7f37', '')], +]); + const fixtureCopilotConnectors: readonly ICopilotConnector[] = [ { name: 'workiq-mail', @@ -1041,7 +1052,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor const discoverEnabled = marketplaceVisibilityEnabled; const marketplaceResources = [ ...(agentFinderPublicFeedEnabled ? customizationMarketplaceResources.map(resource => options.featuredFeedEnabled - ? { ...resource, sourceId: CustomizationMarketplaceSources.AgentFinderPublicFeed.id } + ? { ...resource, sourceId: CustomizationMarketplaceSources.AgentFinderPublicFeed.id, icon: featuredMarketplaceIcons.get(resource.identifier) } : resource) : []), ...(options.copilotConnectorsEnabled ? [copilotConnectorMarketplaceResource] : []), ]; @@ -1795,6 +1806,10 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor === ['Figma', 'Browser tools', 'Documentation workflow', 'Repository review'].join('\n'), 'Discover must preserve the saved featured feed order.', ); + const featuredImages = [...featured?.querySelectorAll('.customization-discovery-card-icon img') ?? []]; + assert(featuredImages.length === 4, 'The saved featured feed fixture must render each enriched resource icon.'); + await Promise.all(featuredImages.map(image => image.decode())); + assert(featuredImages.every(image => image.naturalWidth > 0), 'The saved featured feed fixture icons must decode before capture.'); } const header = ctx.container.querySelector('.customization-discovery-header'); const searchRow = ctx.container.querySelector('.customization-discovery-search-row'); From c20a7aa790f69d75d202ff4fea786cda3fda35d9 Mon Sep 17 00:00:00 2001 From: Paul <8560030+pwang347@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:12:16 -0700 Subject: [PATCH 4/4] test: Wait for featured feed fixture Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aiCustomizationManagementEditor.fixture.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index f4b9027103f112..a3fb2d1976a487 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -7,7 +7,7 @@ import * as DOM from '../../../../../base/browser/dom.js'; import { Dimension } from '../../../../../base/browser/dom.js'; import { mainWindow } from '../../../../../base/browser/window.js'; import { assert } from '../../../../../base/common/assert.js'; -import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; +import { DeferredPromise, retry, timeout } from '../../../../../base/common/async.js'; import { bufferToStream, VSBuffer } from '../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; @@ -1789,6 +1789,13 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor && descriptionLinks.join('\n') === ['Plugins', 'MCP Servers', 'Skills', 'Instructions', 'Agents', 'Hooks'].join('\n'), 'Discover must link each customization type from its description.', ); + if (options.featuredFeedEnabled) { + const expectedNames = ['Figma', 'Browser tools', 'Documentation workflow', 'Repository review']; + await retry(async () => { + const names = [...ctx.container.querySelectorAll('.customization-discovery-section.featured .customization-discovery-card-name')].map(element => element.textContent); + assert(names.join('\n') === expectedNames.join('\n'), 'Discover must preserve the saved featured feed order.'); + }, 10, 100); + } const featured = ctx.container.querySelector('.customization-discovery-section.featured'); const featuredCard = featured?.querySelector('.customization-discovery-card'); const featuredName = featuredCard?.querySelector('.customization-discovery-card-name'); @@ -1801,11 +1808,6 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor 'Featured cards must place source metadata beside the name and the description on the next line.', ); if (options.featuredFeedEnabled) { - assert( - [...featured?.querySelectorAll('.customization-discovery-card-name') ?? []].map(element => element.textContent).join('\n') - === ['Figma', 'Browser tools', 'Documentation workflow', 'Repository review'].join('\n'), - 'Discover must preserve the saved featured feed order.', - ); const featuredImages = [...featured?.querySelectorAll('.customization-discovery-card-icon img') ?? []]; assert(featuredImages.length === 4, 'The saved featured feed fixture must render each enriched resource icon.'); await Promise.all(featuredImages.map(image => image.decode()));