From eb4b2d1887601ea756edc1760b1603bed1d1207a Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Fri, 24 Apr 2026 14:59:59 +0200 Subject: [PATCH 1/3] add ICustomizationHarnessService.getCustomAgents --- .../copilotcli/node/copilotCli.ts | 2 + .../copilotCLICustomizationProvider.ts | 2 + .../remoteAgentHostCustomizationHarness.ts | 4 + .../api/browser/mainThreadChatAgents2.ts | 2 + .../workbench/api/common/extHost.protocol.ts | 1 + .../api/common/extHostChatAgents2.ts | 4 +- .../aiCustomizationItemSource.ts | 6 + ...promptsServiceCustomizationItemProvider.ts | 20 ++- .../contrib/chat/common/chatModes.ts | 11 +- .../common/customizationHarnessService.ts | 74 +++++++++- .../promptSyntax/service/promptsService.ts | 1 - .../service/promptsServiceImpl.ts | 132 ++++++++++-------- .../customizationHarnessService.test.ts | 96 ++++++++++++- 13 files changed, 272 insertions(+), 83 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index 6898c09c7b387..1805638b146a6 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -278,6 +278,8 @@ export interface CLIAgentInfo { readonly agent: Readonly; /** File URI for prompt-file agents, synthetic `copilotcli:` URI for SDK-only agents. */ readonly sourceUri: URI; + readonly extensionId?: string; + readonly pluginUri?: URI; } export interface ICopilotCLIAgents { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts index d0af1a6f79dc7..95ff49d8534e5 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts @@ -189,6 +189,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod uri: s.uri, type: vscode.ChatSessionCustomizationType.Skill, name: s.name, + extensionId: s.extensionId, + pluginUri: s.pluginUri, })); } diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts index 58ef0bf07a00b..a6ef3df422be7 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts @@ -90,6 +90,8 @@ export class RemoteAgentCustomizationItemProvider extends Disposable implements status: toStatusString(sc.status), statusMessage: sc.statusMessage, enabled: sc.enabled, + extensionId: undefined, + pluginUri: undefined })); } @@ -99,6 +101,8 @@ export class RemoteAgentCustomizationItemProvider extends Disposable implements type: 'plugin', name: ref.displayName, description: ref.description, + extensionId: undefined, + pluginUri: undefined })); } } diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index fed4a81015adb..db1ab19404444 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -746,6 +746,8 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA groupKey: item.groupKey, badge: item.badge, badgeTooltip: item.badgeTooltip, + extensionId: undefined, + pluginUri: undefined })); }, }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index f03878df02b2a..2ba3793dc283a 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1738,6 +1738,7 @@ export interface IChatSessionCustomizationItemDto { readonly description?: string; readonly groupKey?: string; readonly badge?: string; + readonly badgeTooltip?: string; } export interface IChatParticipantMetadata { diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 58b98ea5a42df..0b58d688ac7e4 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -830,8 +830,8 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS description: item.description, groupKey: item.groupKey, badge: item.badge, - badgeTooltip: item.badgeTooltip, - })); + badgeTooltip: item.badgeTooltip + } satisfies IChatSessionCustomizationItemDto)); } catch (err) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index f394aa0176355..2d643c9065853 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -156,6 +156,8 @@ export async function expandHookFileItems( enabled: item.enabled, groupKey: item.groupKey, storage: item.storage, + extensionId: item.extensionId, + pluginUri: item.pluginUri }); } } @@ -449,6 +451,8 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour enabled: !disabledPromptFiles.has(p.uri), badge: uiTooltip ? uiIntegrationBadge : undefined, badgeTooltip: uiTooltip, + extensionId: undefined, + pluginUri: undefined }; appended.push(this.itemNormalizer.normalizeItem(builtinItem, promptType, uriUseCounts)); } @@ -484,6 +488,8 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour name: getFriendlyName(basename(file.uri)), groupKey: 'sync-local', enabled: true, + extensionId: undefined, + pluginUri: undefined })); return this.itemNormalizer.normalizeItems(providerItems, promptType) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts index 7e710a7c5232b..7f8c78a4efe9b 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -75,6 +75,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt description: agent.description, storage: agent.source.storage, enabled: !disabledUris.has(agent.uri), + extensionId: agent.source.storage === PromptsStorage.extension ? agent.source.extensionId.value : undefined, + pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined }); if (agent.source.storage === PromptsStorage.extension && !extensionInfoByUri.has(agent.uri)) { extensionInfoByUri.set(agent.uri, { id: agent.source.extensionId }); @@ -104,6 +106,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt enabled: true, badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, badgeTooltip: uiTooltip, + extensionId: skill.extension?.identifier.value, + pluginUri: skill.pluginUri }); } if (disabledUris.size > 0) { @@ -121,6 +125,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt enabled: false, badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, badgeTooltip: uiTooltip, + extensionId: file.extension?.identifier.value, + pluginUri: file.pluginUri }); } } @@ -138,6 +144,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt description: command.description, storage: command.storage, enabled: !disabledUris.has(command.uri), + extensionId: command.extension?.identifier.value, + pluginUri: command.pluginUri }); if (command.extension) { extensionInfoByUri.set(command.uri, { id: command.extension.identifier, displayName: command.extension.displayName }); @@ -166,6 +174,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt name: f.name || getFriendlyName(basename(f.uri)), storage: f.storage, enabled: !disabledUris.has(f.uri), + extensionId: f.extension?.identifier.value, + pluginUri: f.pluginUri }); } @@ -193,6 +203,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt storage: agent.source.storage, groupKey: 'agents', enabled: !disabledUris.has(agent.uri), + extensionId: agent.source.storage === PromptsStorage.extension ? agent.source.extensionId.value : undefined, + pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined }); } } @@ -219,10 +231,12 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt storage, groupKey: 'agent-instructions', enabled: !disabledUris.has(file.uri), + extensionId: undefined, + pluginUri: undefined }); } - for (const { uri, pattern, name, description, storage } of instructionFiles) { + for (const { uri, pattern, name, description, storage, extension, pluginUri } of instructionFiles) { if (agentInstructionUris.has(uri)) { continue; } @@ -246,6 +260,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt storage, groupKey: 'context-instructions', enabled: !disabledUris.has(uri), + extensionId: extension?.identifier.value, + pluginUri }); } else { items.push({ @@ -256,6 +272,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt storage, groupKey: 'on-demand-instructions', enabled: !disabledUris.has(uri), + extensionId: extension?.identifier.value, + pluginUri }); } } diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index ce81c152d2095..b510a3e306b12 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -494,7 +494,7 @@ function serializeChatModeSource(source: IAgentSource | undefined): IChatModeSou return undefined; } if (source.storage === PromptsStorage.extension) { - return { storage: PromptsStorage.extension, extensionId: source.extensionId.value, type: source.type }; + return { storage: PromptsStorage.extension, extensionId: source.extensionId.value }; } if (source.storage === PromptsStorage.plugin) { return { storage: PromptsStorage.plugin, pluginUri: source.pluginUri }; @@ -507,14 +507,7 @@ function reviveChatModeSource(data: IChatModeSourceData | undefined): IAgentSour return undefined; } if (data.storage === PromptsStorage.extension) { - // Migrate old ExtensionAgentSourceType values ('contribution'/'provider') to PromptFileSource values - let type: PromptFileSource.ExtensionContribution | PromptFileSource.ExtensionAPI; - if (data.type === 'provider' as string /* old type value */ || data.type === PromptFileSource.ExtensionAPI) { - type = PromptFileSource.ExtensionAPI; - } else { - type = PromptFileSource.ExtensionContribution; - } - return { storage: PromptsStorage.extension, extensionId: new ExtensionIdentifier(data.extensionId), type }; + return { storage: PromptsStorage.extension, extensionId: new ExtensionIdentifier(data.extensionId) }; } if (data.storage === PromptsStorage.plugin) { return { storage: PromptsStorage.plugin, pluginUri: URI.revive(data.pluginUri) }; diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index a7fa2f855fffb..83b2a474a7b57 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -15,9 +15,11 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { AICustomizationManagementSection, IStorageSourceFilter } from './aiCustomizationWorkspaceService.js'; import { PromptsType } from './promptSyntax/promptTypes.js'; import { AGENT_MD_FILENAME } from './promptSyntax/config/promptFileLocations.js'; -import { IChatPromptSlashCommand, IPromptsService, IResolvedChatPromptSlashCommand, matchesSessionType, PromptsStorage } from './promptSyntax/service/promptsService.js'; +import { IAgentSource, IChatPromptSlashCommand, ICustomAgent, IPromptsService, IResolvedChatPromptSlashCommand, matchesSessionType, PromptsStorage } from './promptSyntax/service/promptsService.js'; 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'; export const ICustomizationHarnessService = createDecorator('customizationHarnessService'); @@ -146,6 +148,10 @@ export interface ICustomizationItem { 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. */ + readonly pluginUri: URI | undefined; /** Server-reported loading status for this customization. */ readonly status?: 'loading' | 'loaded' | 'degraded' | 'error'; /** Human-readable status detail (e.g. error message or warning). */ @@ -265,6 +271,11 @@ export interface ICustomizationHarnessService { */ readonly onDidChangeSlashCommands: Event<{ readonly sessionType: string }>; + /** + * Fires when one of the provided custom agents changes. + */ + readonly onDidChangeCustomAgents: Event<{ readonly sessionType: string }>; + /** * Returns the prompt and skill slash commands for the given session type. * Provider-backed harnesses contribute their own items directly; the default @@ -272,6 +283,13 @@ export interface ICustomizationHarnessService { */ getSlashCommands(sessionType: string, token: CancellationToken): Promise; + /** + * Returns the custom agents for the given session type. + * Provider-backed harnesses select items via their own provider and resolve + * details via the core prompts service. + */ + getCustomAgents(sessionType: string, token: CancellationToken): Promise; + /** * Resolves a slash command to its full metadata, including the parsed prompt file for prompt commands. * Provider-backed harnesses resolve their own items directly; the default VS Code harness falls back to the core prompts service. @@ -481,7 +499,10 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer declare readonly _serviceBrand: undefined; private readonly _onDidChangeSlashCommands = new Emitter<{ readonly sessionType: string }>(); readonly onDidChangeSlashCommands = this._onDidChangeSlashCommands.event; + private readonly _onDidChangeCustomAgents = new Emitter<{ readonly sessionType: string }>(); + readonly onDidChangeCustomAgents = this._onDidChangeCustomAgents.event; private readonly _providerListeners: IDisposable[] = []; + private _isDisposed = false; private readonly _activeHarness: ISettableObservable; readonly activeHarness: IObservable; @@ -516,6 +537,9 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer } private _refreshAvailableHarnesses(): void { + if (this._isDisposed) { + return; + } this._availableHarnesses.set(this._getAllHarnesses(), undefined); this._rebindProviderListeners(); } @@ -529,18 +553,22 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer const provider = harness.itemProvider; if (!provider) { this._providerListeners.push(this.promptsService.onDidChangeSlashCommands(() => this._onDidChangeSlashCommands.fire({ sessionType: harness.id }))); + this._providerListeners.push(this.promptsService.onDidChangeCustomAgents(() => this._onDidChangeCustomAgents.fire({ sessionType: harness.id }))); } else { this._providerListeners.push(provider.onDidChange(() => this._onDidChangeSlashCommands.fire({ sessionType: harness.id }))); + this._providerListeners.push(provider.onDidChange(() => this._onDidChangeCustomAgents.fire({ sessionType: harness.id }))); } } } dispose(): void { + this._isDisposed = true; for (const listener of this._providerListeners) { listener.dispose(); } this._providerListeners.length = 0; this._onDidChangeSlashCommands.dispose(); + this._onDidChangeCustomAgents.dispose(); } registerExternalHarness(descriptor: IHarnessDescriptor): IDisposable { @@ -548,6 +576,9 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer this._refreshAvailableHarnesses(); return { dispose: () => { + if (this._isDisposed) { + return; + } const idx = this._externalHarnesses.indexOf(descriptor); if (idx >= 0) { this._externalHarnesses.splice(idx, 1); @@ -624,6 +655,47 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer return result; } + async getCustomAgents(sessionType: string, token: CancellationToken): Promise { + const harness = this.findHarnessById(sessionType); + if (!harness || !harness.itemProvider) { + const allAgents = await this.promptsService.getCustomAgents(token); + return allAgents.filter(agent => matchesSessionType(agent.sessionTypes, sessionType)); + } + + const items = await harness.itemProvider.provideChatSessionCustomizations(token); + if (!items) { + return []; + } + + const getSource = (item: ICustomizationItem): IAgentSource => { + if (item.storage === PromptsStorage.extension && item.extensionId) { + return { storage: PromptsStorage.extension, extensionId: new ExtensionIdentifier(item.extensionId) }; + } else if (item.storage === PromptsStorage.plugin && item.pluginUri) { + return { storage: PromptsStorage.plugin, pluginUri: item.pluginUri }; + } else if (item.storage === PromptsStorage.user) { + return { storage: PromptsStorage.user }; + } + return { storage: PromptsStorage.local }; + }; + + const result: ICustomAgent[] = []; + for (const item of items) { + if ((item.enabled !== false) && item.type === PromptsType.agent) { + const promptFile = await this.promptsService.parseNew(item.uri, token); + const extra = { + name: item.name, + description: item.description, + sessionTypes: [sessionType], + hooks: undefined, + source: getSource(item), + type: PromptsType.agent, + }; + result.push(CustomAgent.fromParsedPromptFile(promptFile, extra)); + } + } + return result; + } + public async resolvePromptSlashCommand(name: string, sessionType: string, token: CancellationToken): Promise { const harness = this.findHarnessById(sessionType); if (!harness || !harness.itemProvider) { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 70735295b98fd..17fd0c835e7eb 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -196,7 +196,6 @@ export interface IPluginPromptPath extends IPromptPathBase { export type IAgentSource = { readonly storage: PromptsStorage.extension; readonly extensionId: ExtensionIdentifier; - readonly type: PromptFileSource.ExtensionContribution | PromptFileSource.ExtensionAPI; } | { readonly storage: PromptsStorage.local | PromptsStorage.user; } | { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index ef662ec845fb7..74de7de710220 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -791,73 +791,24 @@ export class PromptsService extends Disposable implements IPromptsService { try { const ast = await this.parseNew(uri, token); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let metadata: any | undefined; - if (ast.header) { - const advanced = ast.header.getAttribute(PromptHeaderAttributes.advancedOptions); - if (advanced && advanced.value.type === 'map') { - metadata = {}; - for (const [key, value] of Object.entries(advanced.value)) { - if (value.type === 'scalar') { - metadata[key] = value; - } - } - } - } - const toolReferences: IVariableReference[] = []; - if (ast.body) { - const bodyOffset = ast.body.offset; - const bodyVarRefs = ast.body.variableReferences; - for (let i = bodyVarRefs.length - 1; i >= 0; i--) { // in reverse order - const { name, offset, fullLength } = bodyVarRefs[i]; - const range = new OffsetRange(offset - bodyOffset, offset - bodyOffset + fullLength); - toolReferences.push({ name, range }); - } - } - - const agentInstructions = { - content: ast.body?.getContent() ?? '', - toolReferences, - metadata, - } satisfies IAgentInstructions; - - const name = ast.header?.name ?? promptPath.name ?? getCleanPromptName(uri); - const description = ast.header?.description ?? promptPath.description; - const target = getTarget(PromptsType.agent, ast.header ?? uri); - - const source: IAgentSource = IAgentSource.fromPromptPath(promptPath); - const when = isExtensionPromptPath(promptPath) && promptPath.when - ? ContextKeyExpr.deserialize(promptPath.when) ?? undefined - : undefined; - if (!ast.header) { - const agent: ICustomAgent = { uri, name, agentInstructions, source, target, visibility: { userInvocable: true, agentInvocable: true }, sessionTypes: promptPath.sessionTypes, ...(when !== undefined ? { when } : undefined) }; - return { status: 'loaded', promptPath: this.withPromptPathMetadata(promptPath, name, description), agent }; - } - const visibility = { - userInvocable: ast.header.userInvocable !== false, - agentInvocable: ast.header.infer !== undefined ? ast.header.infer === true : ast.header.disableModelInvocation !== true, - } satisfies ICustomAgentVisibility; - - let model = ast.header.model; - if (target === Target.Claude && model) { - model = mapClaudeModels(model); - } - let { tools, handOffs, argumentHint, agents } = ast.header; - if (target === Target.Claude && tools) { - tools = mapClaudeTools(tools); - } - // Parse hooks from the frontmatter if present let hooks: ChatRequestHooks | undefined; - const hooksRaw = ast.header.hooksRaw; + const hooksRaw = ast.header?.hooksRaw; if (useChatHooks && isWorkspaceTrusted && hooksRaw) { const hookWorkspaceFolder = this.workspaceService.getWorkspaceFolder(uri) ?? defaultFolder; const workspaceRootUri = hookWorkspaceFolder?.uri; + const target = getTarget(PromptsType.agent, ast.header ?? promptPath.uri); hooks = parseSubagentHooksFromYaml(hooksRaw, workspaceRootUri, userHome, target); } - - const agent: ICustomAgent = { uri, name, description, model, tools, handOffs, argumentHint, target, visibility, agents, hooks, agentInstructions, source, sessionTypes: promptPath.sessionTypes, ...(when !== undefined ? { when } : undefined) }; - return { status: 'loaded', promptPath: this.withPromptPathMetadata(promptPath, name, description), agent }; + const extra = { + sessionTypes: promptPath.sessionTypes, + hooks, + name: promptPath.name, + description: promptPath.description, + source: IAgentSource.fromPromptPath(promptPath) + }; + const agent = CustomAgent.fromParsedPromptFile(ast, extra); + return { status: 'loaded', promptPath: this.withPromptPathMetadata(promptPath, agent.name, agent.description), agent }; } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); if (error instanceof FileOperationError && error.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { @@ -1788,13 +1739,70 @@ class ModelChangeTracker extends Disposable { } } +export namespace CustomAgent { + export function fromParsedPromptFile(ast: ParsedPromptFile, extra: { name?: string; description?: string; when?: string; source: IAgentSource; hooks?: ChatRequestHooks; sessionTypes: readonly string[] | undefined }): ICustomAgent { + const uri = ast.uri; + const { hooks, sessionTypes } = extra; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let metadata: any | undefined; + if (ast.header) { + const advanced = ast.header.getAttribute(PromptHeaderAttributes.advancedOptions); + if (advanced && advanced.value.type === 'map') { + metadata = {}; + for (const [key, value] of Object.entries(advanced.value)) { + if (value.type === 'scalar') { + metadata[key] = value; + } + } + } + } + const toolReferences: IVariableReference[] = []; + if (ast.body) { + const bodyOffset = ast.body.offset; + const bodyVarRefs = ast.body.variableReferences; + for (let i = bodyVarRefs.length - 1; i >= 0; i--) { // in reverse order + const { name, offset, fullLength } = bodyVarRefs[i]; + const range = new OffsetRange(offset - bodyOffset, offset - bodyOffset + fullLength); + toolReferences.push({ name, range }); + } + } + + const agentInstructions = { content: ast.body?.getContent() ?? '', toolReferences, metadata } satisfies IAgentInstructions; + + const name = ast.header?.name ?? extra.name ?? getCleanPromptName(uri); + const description = ast.header?.description ?? extra.description; + const target = getTarget(PromptsType.agent, ast.header ?? uri); + + const when = extra.when ? ContextKeyExpr.deserialize(extra.when) ?? undefined : undefined; + const source = extra.source; + if (!ast.header) { + return { uri, name, agentInstructions, source, target, visibility: { userInvocable: true, agentInvocable: true }, sessionTypes, hooks, when }; + } + const visibility = { + userInvocable: ast.header.userInvocable !== false, + agentInvocable: ast.header.infer !== undefined ? ast.header.infer === true : ast.header.disableModelInvocation !== true, + } satisfies ICustomAgentVisibility; + + let model = ast.header.model; + if (target === Target.Claude && model) { + model = mapClaudeModels(model); + } + let { tools, handOffs, argumentHint, agents } = ast.header; + if (target === Target.Claude && tools) { + tools = mapClaudeTools(tools); + } + return { uri, name, description, model, tools, handOffs, argumentHint, target, visibility, agents, agentInstructions, source, sessionTypes, hooks, when }; + + } +} + namespace IAgentSource { export function fromPromptPath(promptPath: IPromptPath): IAgentSource { if (promptPath.storage === PromptsStorage.extension) { return { storage: PromptsStorage.extension, - extensionId: promptPath.extension.identifier, - type: promptPath.source + extensionId: promptPath.extension.identifier }; } else if (promptPath.storage === PromptsStorage.plugin) { return { diff --git a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts index 4eb4cdffc88f3..d0a384e9aec7e 100644 --- a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts @@ -9,8 +9,8 @@ import { URI } from '../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, ICustomizationItemProvider, IHarnessDescriptor, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; -import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; +import { ICustomAgent, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { SessionType } from '../../common/chatSessionsService.js'; import { MockPromptsService } from './promptSyntax/service/mockPromptsService.js'; @@ -55,6 +55,32 @@ suite('CustomizationHarnessService', () => { assert.strictEqual(firedSessionType, harnessId); }); + test('forwards item provider changes via onDidChangeCustomAgents with sessionType', () => { + const service = createService(); + const emitter = new Emitter(); + store.add(emitter); + const harnessId = 'test-harness'; + const externalDescriptor: IHarnessDescriptor = { + id: harnessId, + label: 'Test Harness', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + itemProvider: { + onDidChange: emitter.event, + provideChatSessionCustomizations: async () => [], + }, + }; + + store.add(service.registerExternalHarness(externalDescriptor)); + + let firedSessionType: string | undefined; + const listener = store.add(service.onDidChangeCustomAgents(e => firedSessionType = e.sessionType)); + store.add(listener); + + emitter.fire(); + assert.strictEqual(firedSessionType, harnessId); + }); + test('adds harness to available list', () => { const service = createService(); assert.strictEqual(service.availableHarnesses.get().length, 1); @@ -175,7 +201,7 @@ suite('CustomizationHarnessService', () => { const emitter = new Emitter(); store.add(emitter); const testItems = [ - { uri: URI.parse('file:///workspace/.claude/SKILL.md'), type: 'skill', name: 'Test Skill', description: 'A test skill' }, + { uri: URI.parse('file:///workspace/.claude/SKILL.md'), type: 'skill', name: 'Test Skill', description: 'A test skill', extensionId: undefined, pluginUri: undefined }, ]; const itemProvider: ICustomizationItemProvider = { @@ -346,10 +372,10 @@ suite('CustomizationHarnessService', () => { itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async () => [ - { uri: URI.parse('file:///workspace/.test/prompts/fix.prompt.md'), type: PromptsType.prompt, name: 'fix', description: 'Fix something' }, - { uri: URI.parse('file:///workspace/.test/skills/lint/SKILL.md'), type: PromptsType.skill, name: 'lint', description: 'Lint skill' }, - { uri: URI.parse('file:///workspace/.test/instructions/rule.instructions.md'), type: PromptsType.instructions, name: 'rule', description: 'Ignore me' }, - { uri: URI.parse('file:///workspace/.test/skills/disabled/SKILL.md'), type: PromptsType.skill, name: 'disabled', enabled: false }, + { uri: URI.parse('file:///workspace/.test/prompts/fix.prompt.md'), type: PromptsType.prompt, name: 'fix', description: 'Fix something', extensionId: undefined, pluginUri: undefined }, + { uri: URI.parse('file:///workspace/.test/skills/lint/SKILL.md'), type: PromptsType.skill, name: 'lint', description: 'Lint skill', extensionId: undefined, pluginUri: undefined }, + { uri: URI.parse('file:///workspace/.test/instructions/rule.instructions.md'), type: PromptsType.instructions, name: 'rule', description: 'Ignore me', extensionId: undefined, pluginUri: undefined }, + { uri: URI.parse('file:///workspace/.test/skills/disabled/SKILL.md'), type: PromptsType.skill, name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined }, ], }, }); @@ -391,6 +417,62 @@ suite('CustomizationHarnessService', () => { }); }); + suite('getCustomAgents', () => { + const createAgent = (name: string, path: string, sessionTypes?: readonly string[]): ICustomAgent => ({ + uri: URI.parse(path), + name, + target: Target.GitHubCopilot, + visibility: { userInvocable: true, agentInvocable: true }, + agentInstructions: { content: '', toolReferences: [] }, + source: { storage: PromptsStorage.local }, + sessionTypes, + }); + + test('falls back to promptsService and filters by session type', async () => { + const testSessionType = 'test-session-type'; + const promptsService = new MockPromptsService(); + promptsService.setCustomModes([ + createAgent('matching', 'file:///workspace/.github/agents/matching.agent.md', [testSessionType]), + createAgent('global', 'file:///workspace/.github/agents/global.agent.md'), + createAgent('other', 'file:///workspace/.github/agents/other.agent.md', ['other-session']), + ]); + const service = new CustomizationHarnessServiceBase([createVSCodeHarnessDescriptor([PromptsStorage.extension])], SessionType.Local, promptsService); + store.add(service); + + const agents = await service.getCustomAgents(testSessionType, CancellationToken.None); + assert.deepStrictEqual(agents.map(agent => agent.name), ['matching', 'global']); + }); + + test('uses provider item URIs to scope resolved custom agents', async () => { + const testSessionType = 'test-session-type'; + const promptsService = new MockPromptsService(); + promptsService.setCustomModes([ + createAgent('selected', 'file:///workspace/.test/agents/selected.agent.md', [testSessionType]), + createAgent('not-selected', 'file:///workspace/.test/agents/not-selected.agent.md', [testSessionType]), + ]); + + const emitter = new Emitter(); + store.add(emitter); + const service = new CustomizationHarnessServiceBase([{ + id: testSessionType, + label: 'Test Extension', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + itemProvider: { + onDidChange: emitter.event, + provideChatSessionCustomizations: async () => [ + { uri: URI.parse('file:///workspace/.test/agents/selected.agent.md'), type: PromptsType.agent, name: 'selected', extensionId: undefined, pluginUri: undefined }, + { uri: URI.parse('file:///workspace/.test/agents/disabled.agent.md'), type: PromptsType.agent, name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined }, + ], + }, + }], testSessionType, promptsService); + store.add(service); + + const agents = await service.getCustomAgents(testSessionType, CancellationToken.None); + assert.deepStrictEqual(agents.map(agent => agent.name), ['selected']); + }); + }); + suite('matchesWorkspaceSubpath', () => { test('matches segment boundary', () => { assert.ok(matchesWorkspaceSubpath('/workspace/.claude/skills/SKILL.md', ['.claude'])); From c2172a34dee3eacfbf4286b9c895f9d0533cbc52 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Fri, 24 Apr 2026 16:28:47 +0200 Subject: [PATCH 2/3] fix tests --- .../chat/test/common/promptSyntax/service/mockPromptsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index c4e34dcec8916..47828f1ad080e 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -51,7 +51,7 @@ export class MockPromptsService implements IPromptsService { // eslint-disable-next-line @typescript-eslint/no-explicit-any parse(_uri: URI, _type: any, _token: CancellationToken): Promise { throw new Error('Not implemented'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any - parseNew(_uri: URI, _token: CancellationToken): Promise { throw new Error('Not implemented'); } + parseNew(uri: URI, _token: CancellationToken): Promise { return Promise.resolve({ uri }); } getParsedPromptFile(textModel: ITextModel): ParsedPromptFile { throw new Error('Not implemented'); } registerContributedFile(type: PromptsType, uri: URI, extension: IExtensionDescription, name: string | undefined, description: string | undefined, when?: string, sessionTypes?: readonly string[]): IDisposable { throw new Error('Not implemented'); } getPromptLocationLabel(promptPath: IPromptPath): string { throw new Error('Not implemented'); } From b27b96e3d2ad17e19425d1779ea57af1cddda885 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Fri, 24 Apr 2026 16:33:13 +0200 Subject: [PATCH 3/3] update Co-authored-by: Copilot --- .../promptSyntax/service/promptsService.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 743114b8eaf5e..fc58f19982daf 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -811,7 +811,7 @@ suite('PromptsService', () => { } ]); - const result = (await service.getCustomAgents(CancellationToken.None)).map(agent => ({ ...agent, uri: URI.from(agent.uri) })); + const result = (await service.getCustomAgents(CancellationToken.None)).map(({ when, ...agent }) => ({ ...agent, uri: URI.from(agent.uri) })); const expected: ICustomAgent[] = [ { name: 'agent1', @@ -869,7 +869,7 @@ suite('PromptsService', () => { } ]); - const result = (await service.getCustomAgents(CancellationToken.None)).map(agent => ({ ...agent, uri: URI.from(agent.uri) })); + const result = (await service.getCustomAgents(CancellationToken.None)).map(({ when, ...agent }) => ({ ...agent, uri: URI.from(agent.uri) })); const expected: ICustomAgent[] = [ { name: 'agent1', @@ -901,6 +901,7 @@ suite('PromptsService', () => { ], metadata: undefined }, + hooks: undefined, sessionTypes: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent2.agent.md'), source: { storage: PromptsStorage.local }, @@ -947,7 +948,7 @@ suite('PromptsService', () => { } ]); - const result = (await service.getCustomAgents(CancellationToken.None)).map(agent => ({ ...agent, uri: URI.from(agent.uri) })); + const result = (await service.getCustomAgents(CancellationToken.None)).map(({ when, ...agent }) => ({ ...agent, uri: URI.from(agent.uri) })); const expected: ICustomAgent[] = [ { name: 'agent1', @@ -1039,7 +1040,7 @@ suite('PromptsService', () => { } ]); - const result = (await service.getCustomAgents(CancellationToken.None)).map(agent => ({ ...agent, uri: URI.from(agent.uri) })); + const result = (await service.getCustomAgents(CancellationToken.None)).map(({ when, ...agent }) => ({ ...agent, uri: URI.from(agent.uri) })); const expected: ICustomAgent[] = [ { name: 'github-agent', @@ -1157,7 +1158,7 @@ suite('PromptsService', () => { }, ]); - const result = (await service.getCustomAgents(CancellationToken.None)).map(agent => ({ ...agent, uri: URI.from(agent.uri) })); + const result = (await service.getCustomAgents(CancellationToken.None)).map(({ when, ...agent }) => ({ ...agent, uri: URI.from(agent.uri) })); const expected: ICustomAgent[] = [ { name: 'copilot-agent', @@ -1259,7 +1260,7 @@ suite('PromptsService', () => { } ]); - const result = (await service.getCustomAgents(CancellationToken.None)).map(agent => ({ ...agent, uri: URI.from(agent.uri) })); + const result = (await service.getCustomAgents(CancellationToken.None)).map(({ when, ...agent }) => ({ ...agent, uri: URI.from(agent.uri) })); const expected: ICustomAgent[] = [ { name: 'demonstrate', @@ -1331,7 +1332,7 @@ suite('PromptsService', () => { } ]); - const result = (await service.getCustomAgents(CancellationToken.None)).map(agent => ({ ...agent, uri: URI.from(agent.uri) })); + const result = (await service.getCustomAgents(CancellationToken.None)).map(({ when, ...agent }) => ({ ...agent, uri: URI.from(agent.uri) })); const expected: ICustomAgent[] = [ { name: 'restricted-agent',