add pluginUri and extensionId to ICustomizationItem: fix slash command qualification (#313217)

* add pluginUri and  extensionId to ICustomizationItem: fix slash command qualification

* Update src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* update

* update

* update

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Martin Aeschlimann
2026-04-29 17:35:37 +02:00
committed by GitHub
parent 974736ed9a
commit 21ef72b45b
13 changed files with 126 additions and 82 deletions
@@ -88,11 +88,13 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod
*/
private async getAgentItems(_token: vscode.CancellationToken): Promise<vscode.ChatSessionCustomizationItem[]> {
const agentInfos = await this.copilotCLIAgents.getAgents();
return agentInfos.map(({ agent, sourceUri }) => ({
return agentInfos.map(({ agent, sourceUri, pluginUri, extensionId }) => ({
uri: sourceUri,
type: vscode.ChatSessionCustomizationType.Agent,
name: agent.displayName || agent.name,
description: agent.description,
extensionId,
pluginUri
}));
}
@@ -133,7 +135,10 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod
uri,
type: vscode.ChatSessionCustomizationType.Instructions,
name: basename(uri),
description: undefined,
groupKey: 'agent-instructions',
extensionId: undefined,
pluginUri: undefined
});
}
@@ -166,6 +171,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod
groupKey: 'context-instructions',
badge,
badgeTooltip,
extensionId: instruction.extensionId,
pluginUri: instruction.pluginUri
});
} else {
items.push({
@@ -174,6 +181,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod
name,
description,
groupKey: 'on-demand-instructions',
extensionId: instruction.extensionId,
pluginUri: instruction.pluginUri
});
}
}
@@ -189,6 +198,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod
uri: s.uri,
type: vscode.ChatSessionCustomizationType.Skill,
name: s.name,
description: s.description,
extensionId: s.extensionId,
pluginUri: s.pluginUri,
}));
@@ -203,6 +213,9 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod
uri: h.uri,
type: vscode.ChatSessionCustomizationType.Hook,
name: basename(h.uri).replace(/\.json$/i, ''),
description: undefined,
extensionId: h.extensionId,
pluginUri: h.pluginUri,
}));
}
@@ -214,6 +227,9 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod
uri: p.uri,
type: vscode.ChatSessionCustomizationType.Plugins,
name: basename(p.uri),
description: undefined,
extensionId: undefined,
pluginUri: undefined,
}));
}
}
@@ -65,7 +65,7 @@ function makeSkill(uri: URI, name: string): vscode.ChatSkill {
/** Creates a ChatHook stub. */
function makeHook(uri: URI): vscode.ChatHook {
return { uri };
return { uri, source: 'local' };
}
/** Creates a ChatPlugin stub. */
@@ -108,6 +108,8 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch
type: vscode.ChatSessionCustomizationType.Agent,
name: agent.name,
description: agent.description,
extensionId: undefined,
pluginUri: undefined,
// No groupKey — vscode infers Built-in from non-file: scheme
});
}
@@ -121,6 +123,9 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch
uri: agent.uri,
type: vscode.ChatSessionCustomizationType.Agent,
name,
description: agent.description,
extensionId: agent.extensionId,
pluginUri: agent.pluginUri,
});
}
}
@@ -142,6 +147,9 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch
uri: skill.uri,
type: vscode.ChatSessionCustomizationType.Skill,
name: skill.name,
description: skill.description,
extensionId: skill.extensionId,
pluginUri: skill.pluginUri,
};
skillItems.push(item);
}
@@ -183,6 +191,9 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch
uri,
type: vscode.ChatSessionCustomizationType.Instructions,
name,
description: undefined,
extensionId: undefined,
pluginUri: undefined,
});
}
}
@@ -225,6 +236,8 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch
type: vscode.ChatSessionCustomizationType.Hook,
name: `${eventId}${matcherLabel}`,
description: hook.command,
extensionId: undefined,
pluginUri: undefined,
});
}
}
@@ -271,12 +271,15 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA
return {
uri: hookFile.uri,
sessionTypes: hookFile.sessionTypes,
source: this._toChatResourceSource(hookFile.storage),
extensionId: hookFile.extension?.identifier.value,
pluginUri: hookFile.pluginUri,
};
}
private _toPluginDto(plugin: IAgentPlugin): IPluginDto {
return {
uri: plugin.uri
uri: plugin.uri,
};
}
@@ -753,8 +756,8 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA
groupKey: item.groupKey,
badge: item.badge,
badgeTooltip: item.badgeTooltip,
extensionId: undefined,
pluginUri: undefined
extensionId: item.extensionId,
pluginUri: item.pluginUri ? URI.revive(item.pluginUri) : undefined
}));
},
};
@@ -1719,6 +1719,9 @@ export interface ISlashCommandDto extends IChatResourceDto {
export interface IHookDto {
readonly uri: UriComponents;
readonly sessionTypes?: readonly string[];
readonly source: IChatResourceSourceDto;
readonly extensionId?: string;
readonly pluginUri?: UriComponents;
}
export interface IPluginDto {
@@ -1738,7 +1741,8 @@ export interface IChatSessionCustomizationItemDto {
readonly description?: string;
readonly groupKey?: string;
readonly badge?: string;
readonly extensionId?: string;
readonly pluginUri?: UriComponents;
readonly badgeTooltip?: string;
}
export interface IChatParticipantMetadata {
@@ -589,7 +589,13 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS
}
private toHook(dto: IHookDto): vscode.ChatHook {
return Object.freeze({ uri: URI.revive(dto.uri), sessionTypes: dto.sessionTypes });
return Object.freeze({
uri: URI.revive(dto.uri),
sessionTypes: dto.sessionTypes,
source: dto.source,
extensionId: dto.extensionId,
pluginUri: dto.pluginUri ? URI.revive(dto.pluginUri) : undefined,
});
}
private toPlugin(dto: IPluginDto): vscode.ChatPlugin {
@@ -830,7 +836,9 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS
description: item.description,
groupKey: item.groupKey,
badge: item.badge,
badgeTooltip: item.badgeTooltip
badgeTooltip: item.badgeTooltip,
extensionId: item.extensionId,
pluginUri: item.pluginUri,
} satisfies IChatSessionCustomizationItemDto));
} catch (err) {
return undefined;
@@ -7,16 +7,17 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js
import { Emitter, Event } from '../../../../../../base/common/event.js';
import { Disposable } from '../../../../../../base/common/lifecycle.js';
import { ResourceMap } from '../../../../../../base/common/map.js';
import { basename, dirname, isEqualOrParent } from '../../../../../../base/common/resources.js';
import { basename, isEqualOrParent } from '../../../../../../base/common/resources.js';
import { URI } from '../../../../../../base/common/uri.js';
import { type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
import { type CustomizationRef } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { PromptsType } from '../../../common/promptSyntax/promptTypes.js';
import { IPromptPath, IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js';
import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js';
import { type ICustomizationSyncProvider, type ICustomizationItem, type ICustomizationItemProvider } from '../../../common/customizationHarnessService.js';
import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js';
import { getFriendlyName } from '../../aiCustomization/aiCustomizationItemSource.js';
import type { SyncedCustomizationBundler } from './syncedCustomizationBundler.js';
import { getSkillFolderName } from '../../../common/promptSyntax/config/promptFileLocations.js';
/**
* Prompt types that participate in auto-sync to an agent host harness.
@@ -48,6 +49,8 @@ export interface ILocalCustomizationFile {
readonly type: PromptsType;
readonly storage: PromptsStorage;
readonly disabled: boolean;
readonly pluginUri?: URI;
readonly extensionId?: string;
}
/**
@@ -70,11 +73,13 @@ export async function enumerateLocalCustomizationsForHarness(
);
for (let i = 0; i < lists.length; i++) {
const storage = SYNCABLE_STORAGE_SOURCES[i];
for (const file of lists[i] as readonly IPromptPath[]) {
for (const file of lists[i]) {
result.push({
uri: file.uri,
type,
storage,
pluginUri: file.pluginUri,
extensionId: file.extension?.identifier.value,
disabled: syncProvider.isDisabled(file.uri),
});
}
@@ -131,7 +136,7 @@ export class LocalAgentHostCustomizationItemProvider extends Disposable implemen
let description: string | undefined;
if (file.type === PromptsType.skill) {
const parsed = skillByUri.get(file.uri);
name = parsed?.name ?? basename(dirname(file.uri));
name = parsed?.name ?? getSkillFolderName(file.uri);
description = parsed?.description;
} else {
name = getFriendlyName(basename(file.uri));
@@ -143,8 +148,8 @@ export class LocalAgentHostCustomizationItemProvider extends Disposable implemen
description,
storage: file.storage,
enabled: !file.disabled,
extensionId: undefined,
pluginUri: undefined,
extensionId: file.extensionId,
pluginUri: file.pluginUri,
};
});
}
@@ -169,8 +169,11 @@ async function appendProviderData(lines: string[], provider: ICustomizationItemP
if (item.itemKey) {
lines.push(` itemKey: ${item.itemKey}`);
}
if (item.extensionLabel) {
lines.push(` extensionLabel: ${item.extensionLabel}`);
if (item.extensionId) {
lines.push(` extensionId: ${item.extensionId}`);
}
if (item.pluginUri) {
lines.push(` pluginUri: ${item.pluginUri.toString()}`);
}
if (item.badge) {
lines.push(` badge: ${item.badge}`);
@@ -62,7 +62,7 @@ export interface IAICustomizationListItem {
/** True when item comes from the default chat extension (grouped under Built-in). */
readonly isBuiltin?: boolean;
/** Display name of the contributing extension (for non-built-in extension items). */
readonly extensionLabel?: string;
readonly extensionId?: string;
/** Server-reported loading/sync status for remote customizations. */
readonly status?: 'loading' | 'loaded' | 'degraded' | 'error';
/** Human-readable status detail (e.g. error message or warning). */
@@ -199,7 +199,7 @@ export class AICustomizationItemNormalizer {
}
normalizeItem(item: ICustomizationItem, promptType: PromptsType, uriUseCounts = new ResourceMap<number>()): IAICustomizationListItem {
const { storage, groupKey, isBuiltin, extensionLabel } = this.resolveSource(item);
const { storage, groupKey, isBuiltin, extensionId, pluginUri } = this.inferStorageAndGroup(item);
const seenCount = uriUseCounts.get(item.uri) ?? 0;
uriUseCounts.set(item.uri, seenCount + 1);
const duplicateSuffix = seenCount === 0 ? '' : `#${seenCount}`;
@@ -217,64 +217,49 @@ export class AICustomizationItemNormalizer {
promptType,
disabled: item.enabled === false,
groupKey,
pluginUri: storage === PromptsStorage.plugin ? this.findPluginUri(item.uri) : undefined,
pluginUri,
displayName: item.name,
badge: item.badge,
badgeTooltip: item.badgeTooltip,
typeIcon: promptType === PromptsType.instructions && storage ? storageToIcon(storage) : undefined,
isBuiltin,
extensionLabel,
extensionId,
status: item.status,
statusMessage: item.statusMessage,
};
}
private resolveSource(item: ICustomizationItem): { storage?: PromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionLabel?: string } {
const inferred = this.inferStorageAndGroup(item.uri);
private inferStorageAndGroup(item: ICustomizationItem): { storage: PromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionId?: string; pluginUri?: URI } {
const groupKey = item.groupKey;
const isBuiltin = groupKey === BUILTIN_STORAGE;
// Use provider-supplied values when available; otherwise fall back to URI inference.
const storage = item.storage ?? inferred.storage;
const extensionLabel = item.extensionLabel ?? inferred.extensionLabel;
if (!item.groupKey) {
return { ...inferred, storage, extensionLabel };
}
switch (item.groupKey) {
case BUILTIN_STORAGE: {
// Preserve a provider-supplied BUILTIN_STORAGE so the management
// editor's "edit built-in and save as user/workspace copy" flow
// activates. Otherwise fall back to extension storage (the
// historical source of built-in items).
const builtinStorage = (item.storage as PromptsStorage | typeof BUILTIN_STORAGE | undefined) === BUILTIN_STORAGE
? (BUILTIN_STORAGE as unknown as PromptsStorage)
: PromptsStorage.extension;
return { storage: builtinStorage, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionLabel };
if (item.extensionId) {
const extensionIdentifier = new ExtensionIdentifier(item.extensionId);
if (isChatExtensionItem(extensionIdentifier, this.productService)) {
return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionId: item.extensionId };
}
default:
return { storage, groupKey: item.groupKey, extensionLabel };
return { storage: PromptsStorage.extension, extensionId: item.extensionId, groupKey, isBuiltin };
}
if (item.pluginUri) {
return { storage: PromptsStorage.plugin, pluginUri: item.pluginUri, groupKey, isBuiltin };
}
}
private inferStorageAndGroup(uri: URI): { storage?: PromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionLabel?: string } {
if (uri.scheme !== Schemas.file) {
return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true };
}
const uri = item.uri;
const activeProjectRoot = this.workspaceService.getActiveProjectRoot();
if (activeProjectRoot && isEqualOrParent(uri, activeProjectRoot)) {
return { storage: PromptsStorage.local };
return { storage: PromptsStorage.local, groupKey, isBuiltin };
}
for (const folder of this.workspaceContextService.getWorkspace().folders) {
if (isEqualOrParent(uri, folder.uri)) {
return { storage: PromptsStorage.local };
return { storage: PromptsStorage.local, groupKey, isBuiltin };
}
}
for (const plugin of this.agentPluginService.plugins.get()) {
if (isEqualOrParent(uri, plugin.uri)) {
return { storage: PromptsStorage.plugin };
return { storage: PromptsStorage.plugin, pluginUri: plugin.uri, groupKey, isBuiltin };
}
}
@@ -282,22 +267,13 @@ export class AICustomizationItemNormalizer {
if (extensionId) {
const extensionIdentifier = new ExtensionIdentifier(extensionId);
if (isChatExtensionItem(extensionIdentifier, this.productService)) {
return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true };
return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionId };
}
return { storage: PromptsStorage.extension, extensionLabel: extensionIdentifier.value };
return { storage: PromptsStorage.extension, extensionId, groupKey, isBuiltin };
}
return { storage: PromptsStorage.user };
}
private findPluginUri(itemUri: URI): URI | undefined {
for (const plugin of this.agentPluginService.plugins.get()) {
if (isEqualOrParent(itemUri, plugin.uri)) {
return plugin.uri;
}
}
return undefined;
}
}
// #endregion
@@ -285,8 +285,8 @@ class AICustomizationItemRenderer implements IListRenderer<IFileItemEntry, IAICu
let content: string;
if (element.isBuiltin) {
content = `${element.name}\n${localize('builtinSource', "Built-in")}`;
} else if (element.extensionLabel) {
content = `${element.name}\n${localize('fromExtension', "Extension: {0}", element.extensionLabel)}`;
} else if (element.extensionId) {
content = `${element.name}\n${localize('fromExtension', "Extension: {0}", element.extensionId)}`;
} else {
const isWorkspaceItem = element.storage === PromptsStorage.local;
const uriLabel = this.labelService.getUriLabel(element.uri, { relative: isWorkspaceItem });
@@ -20,6 +20,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js';
import { SessionType } from './chatSessionsService.js';
import { CustomAgent } from './promptSyntax/service/promptsServiceImpl.js';
import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';
import { getCanonicalPluginCommandId } from './plugins/agentPluginService.js';
export const ICustomizationHarnessService = createDecorator<ICustomizationHarnessService>('customizationHarnessService');
@@ -164,8 +165,6 @@ export interface ICustomizationItem {
readonly description?: string;
/** Storage origin (local, user, extension, plugin). Set by providers that know the source. */
readonly storage?: PromptsStorage;
/** Display name of the contributing extension (e.g. "GitHub Copilot Chat"). */
readonly extensionLabel?: string;
/** The extension identifier that contributed this customization, if any. */
readonly extensionId: string | undefined;
/** The URI of the plugin that contributed this customization, if any. */
@@ -644,7 +643,7 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer
result.push({
uri: item.uri,
type: item.type as PromptsType.prompt | PromptsType.skill,
name: item.name,
name: item.pluginUri ? getCanonicalPluginCommandId({ uri: item.pluginUri }, item.name) : item.name,
description: item.description,
userInvocable: true, // todo we need a way for providers to specify this if some items aren't user-invocable`
storage: item.storage ?? PromptsStorage.local,
@@ -697,23 +696,13 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer
}
public async resolvePromptSlashCommand(name: string, sessionType: string, token: CancellationToken): Promise<IResolvedChatPromptSlashCommand | undefined> {
const harness = this.findHarnessById(sessionType);
if (!harness || !harness.itemProvider) {
return this.promptsService.resolvePromptSlashCommand(name, sessionType, token);
}
const items = await harness.itemProvider.provideChatSessionCustomizations(token);
const item = items?.find(cmd => cmd.name === name);
if (item) {
const parsedPromptFile = await this.promptsService.parseNew(item.uri, token);
const commands = await this.getSlashCommands(sessionType, token);
const command = commands.find(cmd => cmd.name === name);
if (command) {
const parsedPromptFile = await this.promptsService.parseNew(command.uri, token);
return {
uri: item.uri,
type: item.type as PromptsType.prompt | PromptsType.skill,
name: item.name,
description: item.description,
userInvocable: parsedPromptFile.header?.userInvocable ?? true,
storage: item.storage ?? PromptsStorage.local,
sessionTypes: [sessionType],
...command,
userInvocable: parsedPromptFile.header?.userInvocable ?? command.userInvocable,
parsedPromptFile,
};
}
+17
View File
@@ -244,6 +244,22 @@ declare module 'vscode' {
* Optional session types that describe when the hook should be offered.
*/
readonly sessionTypes?: readonly string[];
/**
* Where the chat resource was loaded from.
*/
readonly source: ChatResourceSource;
/**
* The contributing extension identifier when {@link source} is `extension`.
*/
readonly extensionId?: string;
/**
* The contributing plugin URI when {@link source} is `plugin`.
*/
readonly pluginUri?: Uri;
}
export interface ChatPlugin {
@@ -252,6 +268,7 @@ declare module 'vscode' {
* Optional session types that describe when the plugin should be offered.
*/
readonly sessionTypes?: readonly string[];
}
// #endregion
@@ -89,6 +89,16 @@ declare module 'vscode' {
*/
readonly description?: string;
/**
* The extension identifier that contributed this customization, if any.
*/
readonly extensionId?: string;
/**
* The URI of the plugin that contributed this customization, if any.
*/
readonly pluginUri?: Uri;
/**
* Optional group key for display grouping. Items sharing the same
* `groupKey` are placed under a shared collapsible header in the