diff --git a/extensions/prompt-basics/package.json b/extensions/prompt-basics/package.json index 1765ac15d8c64..fdb026f68b816 100644 --- a/extensions/prompt-basics/package.json +++ b/extensions/prompt-basics/package.json @@ -47,7 +47,8 @@ ".chatmode.md" ], "filenamePatterns": [ - "**/.github/agents/*.md" + "**/.github/agents/*.md", + "**/.claude/agents/*.md" ], "configuration": "./language-configuration.json" }, diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index ff03a4efa0fd4..fff665a40c0cb 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -55,7 +55,7 @@ import { ILanguageModelToolsService } from '../common/tools/languageModelToolsSe import { HooksExecutionService, IHooksExecutionService } from '../common/hooks/hooksExecutionService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { PromptsConfig } from '../common/promptSyntax/config/config.js'; -import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME, DEFAULT_HOOK_FILE_PATHS } from '../common/promptSyntax/config/promptFileLocations.js'; +import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME, CLAUDE_AGENTS_SOURCE_FOLDER, DEFAULT_HOOK_FILE_PATHS } from '../common/promptSyntax/config/promptFileLocations.js'; import { PromptLanguageFeaturesProvider } from '../common/promptSyntax/promptFileContributions.js'; import { AGENT_DOCUMENTATION_URL, INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL, HOOK_DOCUMENTATION_URL } from '../common/promptSyntax/promptTypes.js'; import { hookFileSchema, HOOK_SCHEMA_URI, HOOK_FILE_GLOB } from '../common/promptSyntax/hookSchema.js'; @@ -816,6 +816,7 @@ configurationRegistry.registerConfiguration({ ), default: { [AGENTS_SOURCE_FOLDER]: true, + [CLAUDE_AGENTS_SOURCE_FOLDER]: true, }, additionalProperties: { type: 'boolean' }, propertyNames: { diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index a7a60c021a190..52355040e33b5 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -50,6 +50,7 @@ import { IEditorGroupsService } from '../../../../services/editor/common/editorG import { LocalChatSessionUri } from '../../common/model/chatUri.js'; import { assertNever } from '../../../../../base/common/assert.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { Target } from '../../common/promptSyntax/service/promptsService.js'; const extensionPoint = ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'chatSessions', @@ -1101,9 +1102,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ * Get the customAgentTarget for a specific session type. * When set, the mode picker should show filtered custom agents matching this target. */ - public getCustomAgentTargetForSessionType(chatSessionType: string): string | undefined { + public getCustomAgentTargetForSessionType(chatSessionType: string): Target { const contribution = this._contributions.get(chatSessionType)?.contribution; - return contribution?.customAgentTarget; + return contribution?.customAgentTarget ?? Target.Undefined; } public getContentProviderSchemes(): string[] { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts index 1937a193db796..96e913156aaf9 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts @@ -12,6 +12,7 @@ import { ITextModel } from '../../../../../editor/common/model.js'; import { ILanguageModelToolsService, IToolAndToolSetEnablementMap } from '../../common/tools/languageModelToolsService.js'; import { PromptHeaderAttributes } from '../../common/promptSyntax/promptFileParser.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; +import { formatArrayValue } from '../../common/promptSyntax/utils/promptEditHelper.js'; export class PromptFileRewriter { constructor( @@ -43,13 +44,14 @@ export class PromptFileRewriter { this.rewriteAttribute(model, '', toolsAttr.range); return; } else { - this.rewriteTools(model, newTools, toolsAttr.value.range); + this.rewriteTools(model, newTools, toolsAttr.value.range, toolsAttr.value.type === 'string'); } } - public rewriteTools(model: ITextModel, newTools: IToolAndToolSetEnablementMap, range: Range): void { + public rewriteTools(model: ITextModel, newTools: IToolAndToolSetEnablementMap, range: Range, isString: boolean): void { const newToolNames = this._languageModelToolsService.toFullReferenceNames(newTools); - const newValue = `[${newToolNames.map(s => `'${s}'`).join(', ')}]`; + const newEntries = newToolNames.map(toolName => formatArrayValue(toolName)).join(', '); + const newValue = isString ? newEntries : `[${newEntries}]`; this.rewriteAttribute(model, newValue, range); } @@ -83,3 +85,4 @@ export class PromptFileRewriter { this.rewriteAttribute(model, newName, nameAttr.value.range); } } + diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts index 3a8d9fda95152..021f38acfd457 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts @@ -15,13 +15,14 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { showToolsPicker } from '../actions/chatToolPicker.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId, PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, Target } from '../../common/promptSyntax/service/promptsService.js'; import { registerEditorFeature } from '../../../../../editor/common/editorFeatures.js'; import { PromptFileRewriter } from './promptFileRewriter.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { IEditorModel } from '../../../../../editor/common/editorCommon.js'; -import { PromptHeaderAttributes } from '../../common/promptSyntax/promptFileParser.js'; -import { isGithubTarget } from '../../common/promptSyntax/languageProviders/promptValidator.js'; +import { isTarget, parseCommaSeparatedList, PromptHeaderAttributes } from '../../common/promptSyntax/promptFileParser.js'; +import { getTarget, isVSCodeOrDefaultTarget } from '../../common/promptSyntax/languageProviders/promptValidator.js'; +import { isBoolean } from '../../../../../base/common/types.js'; class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider { @@ -40,10 +41,10 @@ class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider this._register(this.languageService.codeLensProvider.register(ALL_PROMPTS_LANGUAGE_SELECTOR, this)); this._register(CommandsRegistry.registerCommand(this.cmdId, (_accessor, ...args) => { - const [first, second, third, forth] = args; - const model = first as IEditorModel; - if (isITextModel(model) && Range.isIRange(second) && Array.isArray(third) && (typeof forth === 'string' || forth === undefined)) { - this.updateTools(model as ITextModel, Range.lift(second), third, forth); + const [modelArg, rangeArg, isStringArg, toolsArg, targetArg] = args; + const model = modelArg as IEditorModel; + if (isITextModel(model) && Range.isIRange(rangeArg) && isBoolean(isStringArg) && Array.isArray(toolsArg) && isTarget(targetArg)) { + this.updateTools(model as ITextModel, Range.lift(rangeArg), isStringArg, toolsArg, targetArg); } })); } @@ -61,15 +62,23 @@ class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider return undefined; } - if (isGithubTarget(promptType, header.target)) { + const target = getTarget(promptType, header); + if (!isVSCodeOrDefaultTarget(target)) { return undefined; } const toolsAttr = header.getAttribute(PromptHeaderAttributes.tools); - if (!toolsAttr || toolsAttr.value.type !== 'array') { + if (!toolsAttr) { return undefined; } - const items = toolsAttr.value.items; + let value = toolsAttr.value; + if (value.type === 'string') { + value = parseCommaSeparatedList(value); + } + if (value.type !== 'array') { + return undefined; + } + const items = value.items; const selectedTools = items.filter(item => item.type === 'string').map(item => item.value); const codeLens: CodeLens = { @@ -77,19 +86,19 @@ class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider command: { title: localize('configure-tools.capitalized.ellipsis', "Configure Tools..."), id: this.cmdId, - arguments: [model, toolsAttr.value.range, selectedTools, header.target] + arguments: [model, toolsAttr.range, toolsAttr.value.type === 'string', selectedTools, target] } }; return { lenses: [codeLens] }; } - private async updateTools(model: ITextModel, range: Range, selectedTools: readonly string[], target: string | undefined): Promise { - const selectedToolsNow = () => this.languageModelToolsService.toToolAndToolSetEnablementMap(selectedTools, target, undefined); + private async updateTools(model: ITextModel, range: Range, isString: boolean, selectedTools: readonly string[], target: Target): Promise { + const selectedToolsNow = () => this.languageModelToolsService.toToolAndToolSetEnablementMap(selectedTools, undefined); const newSelectedAfter = await this.instantiationService.invokeFunction(showToolsPicker, localize('placeholder', "Select tools"), 'codeLens', undefined, selectedToolsNow); if (!newSelectedAfter) { return; } - this.instantiationService.createInstance(PromptFileRewriter).rewriteTools(model, newSelectedAfter, range); + this.instantiationService.createInstance(PromptFileRewriter).rewriteTools(model, newSelectedAfter, range, isString); } } diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 12e0a67ae2973..ad8a275652bbb 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -1251,7 +1251,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo * @param fullReferenceNames A list of tool or toolset by their full reference names that are enabled. * @returns A map of tool or toolset instances to their enablement state. */ - toToolAndToolSetEnablementMap(fullReferenceNames: readonly string[], _target: string | undefined, model: ILanguageModelChatMetadata | undefined): IToolAndToolSetEnablementMap { + toToolAndToolSetEnablementMap(fullReferenceNames: readonly string[], model: ILanguageModelChatMetadata | undefined): IToolAndToolSetEnablementMap { const toolOrToolSetNames = new Set(fullReferenceNames); const result = new Map(); for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 5648329edf155..9f2b79de5900c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -66,7 +66,7 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common import { ILanguageModelToolsService, isToolSet } from '../../common/tools/languageModelToolsService.js'; import { ComputeAutomaticInstructions } from '../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../common/promptSyntax/config/config.js'; -import { IHandOff, PromptHeader, Target } from '../../common/promptSyntax/promptFileParser.js'; +import { IHandOff, PromptHeader } from '../../common/promptSyntax/promptFileParser.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { handleModeSwitch } from '../actions/chatActions.js'; import { ChatTreeItem, IChatAcceptInputOptions, IChatAccessibilityService, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidget, IChatWidgetService, IChatWidgetViewContext, IChatWidgetViewModelChangeEvent, IChatWidgetViewOptions, isIChatResourceViewContext, isIChatViewViewContext } from '../chat.js'; @@ -2509,7 +2509,7 @@ export class ChatWidget extends Disposable implements IChatWidget { // if not tools to enable are present, we are done if (tools !== undefined && this.input.currentModeKind === ChatModeKind.Agent) { - const enablementMap = this.toolsService.toToolAndToolSetEnablementMap(tools, Target.VSCode, this.input.selectedLanguageModel.get()?.metadata); + const enablementMap = this.toolsService.toToolAndToolSetEnablementMap(tools, this.input.selectedLanguageModel.get()?.metadata); this.input.selectedToolsModel.set(enablementMap, true); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index ff765e49c3ca4..00b4229ebb0c6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -125,6 +125,7 @@ import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionIte import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; import { WorkspacePickerActionItem } from './workspacePickerActionItem.js'; import { ChatContextUsageWidget } from '../../widgetHosts/viewPane/chatContextUsageWidget.js'; +import { Target } from '../../../common/promptSyntax/service/promptsService.js'; const $ = dom.$; @@ -1426,7 +1427,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Check if this session type has a customAgentTarget const customAgentTarget = ctx && this.chatSessionsService.getCustomAgentTargetForSessionType(ctx.chatSessionType); - this.chatSessionHasCustomAgentTarget.set(!!customAgentTarget); + this.chatSessionHasCustomAgentTarget.set(customAgentTarget !== Target.Undefined); // Handle agent option from session - set initial mode if (customAgentTarget) { @@ -2000,7 +2001,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge customAgentTarget: () => { const sessionResource = this._widget?.viewModel?.model.sessionResource; const ctx = sessionResource && this.chatService.getChatSessionFromInternalUri(sessionResource); - return ctx && this.chatSessionsService.getCustomAgentTargetForSessionType(ctx.chatSessionType); + return (ctx && this.chatSessionsService.getCustomAgentTargetForSessionType(ctx.chatSessionType)) ?? Target.Undefined; }, }; return this.modeWidget = this.instantiationService.createInstance(ModePickerActionItem, action, delegate, pickerOptions); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts index 0c15407e6360a..bf0b3dfb98ca4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts @@ -138,8 +138,7 @@ export class ChatSelectedTools extends Disposable { if (!currentMap && currentMode.kind === ChatModeKind.Agent) { const modeTools = currentMode.customTools?.read(r); if (modeTools) { - const target = currentMode.target?.read(r); - currentMap = ToolEnablementStates.fromMap(this._toolsService.toToolAndToolSetEnablementMap(modeTools, target, lm)); + currentMap = ToolEnablementStates.fromMap(this._toolsService.toToolAndToolSetEnablementMap(modeTools, lm)); } } if (!currentMap) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index 5f012ae67d360..ca2ba9b331c50 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -28,7 +28,7 @@ import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatMode, IChatMode, IChatModeService } from '../../../common/chatModes.js'; import { isOrganizationPromptFile } from '../../../common/promptSyntax/utils/promptsServiceUtils.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; -import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { PromptsStorage, Target } from '../../../common/promptSyntax/service/promptsService.js'; import { getOpenChatActionIdForMode } from '../../actions/chatActions.js'; import { IToggleChatModeArgs, ToggleAgentModeActionId } from '../../actions/chatExecuteActions.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; @@ -41,7 +41,7 @@ export interface IModePickerDelegate { * When set, the mode picker will show custom agents whose target matches this value. * Custom agents without a target are always shown in all session types. If no agents match the target, shows a default "Agent" option. */ - readonly customAgentTarget?: () => string | undefined; + readonly customAgentTarget?: () => Target; } // TODO: there should be an icon contributed for built-in modes @@ -65,7 +65,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { @IOpenerService openerService: IOpenerService ) { // Get custom agent target (if filtering is enabled) - const customAgentTarget = delegate.customAgentTarget?.(); + const customAgentTarget = delegate.customAgentTarget?.() ?? Target.Undefined; // Category definitions const builtInCategory = { label: localize('built-in', "Built-In"), order: 0 }; @@ -107,7 +107,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { openerService.open(modeResource.get()); } }); - } else if (!customAgentTarget) { + } else if (customAgentTarget === Target.Undefined) { const label = localize('configureToolsFor', "Configure tools for {0} agent", mode.label.get()); toolbarActions.push({ id: `configureTools:${mode.id}`, @@ -182,8 +182,8 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const modes = chatModeService.getModes(); const currentMode = delegate.currentMode.get(); const filteredCustomModes = modes.custom.filter(mode => { - const target = mode.target?.get(); - return isUserDefinedCustomAgent(mode) && (!target || target === customAgentTarget); + const target = mode.target.get(); + return isUserDefinedCustomAgent(mode) && (target === customAgentTarget); }); // Always include the default "Agent" option first const checked = currentMode.id === ChatMode.Agent.id; @@ -230,7 +230,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { }; const modePickerActionWidgetOptions: Omit = { - actionProvider: customAgentTarget ? actionProviderWithCustomAgentTarget : actionProvider, + actionProvider: customAgentTarget !== Target.Undefined ? actionProviderWithCustomAgentTarget : actionProvider, actionBarActionProvider: { getActions: () => this.getModePickerActionBarActions() }, diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index 4e0ac16acd748..f4d5712527aca 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -19,8 +19,8 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IChatAgentService } from './participants/chatAgents.js'; import { ChatContextKeys } from './actions/chatContextKeys.js'; import { ChatConfiguration, ChatModeKind } from './constants.js'; -import { IHandOff } from './promptSyntax/promptFileParser.js'; -import { ExtensionAgentSourceType, IAgentSource, ICustomAgent, ICustomAgentVisibility, IPromptsService, isCustomAgentVisibility, PromptsStorage } from './promptSyntax/service/promptsService.js'; +import { IHandOff, isTarget } from './promptSyntax/promptFileParser.js'; +import { ExtensionAgentSourceType, IAgentSource, ICustomAgent, ICustomAgentVisibility, IPromptsService, isCustomAgentVisibility, PromptsStorage, Target } from './promptSyntax/service/promptsService.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { isString } from '../../../../base/common/types.js'; @@ -121,7 +121,7 @@ export class ChatModeService extends Disposable implements IChatModeService { argumentHint: cachedMode.argumentHint, agentInstructions: cachedMode.modeInstructions ?? { content: cachedMode.body ?? '', toolReferences: [] }, handOffs: cachedMode.handOffs, - target: cachedMode.target, + target: cachedMode.target ?? Target.Undefined, visibility: cachedMode.visibility ?? { userInvokable: true, agentInvokable: cachedMode.infer !== false }, agents: cachedMode.agents, source: reviveChatModeSource(cachedMode.source) ?? { storage: PromptsStorage.local } @@ -248,7 +248,7 @@ export interface IChatModeData { readonly handOffs?: readonly IHandOff[]; readonly uri?: URI; readonly source?: IChatModeSourceData; - readonly target?: string; + readonly target?: Target; readonly visibility?: ICustomAgentVisibility; readonly agents?: readonly string[]; readonly infer?: boolean; // deprecated, only available in old cached data @@ -269,7 +269,7 @@ export interface IChatMode { readonly modeInstructions?: IObservable; readonly uri?: IObservable; readonly source?: IAgentSource; - readonly target?: IObservable; + readonly target: IObservable; readonly visibility?: IObservable; readonly agents?: IObservable; } @@ -302,7 +302,7 @@ function isCachedChatModeData(data: unknown): data is IChatModeData { (mode.handOffs === undefined || Array.isArray(mode.handOffs)) && (mode.uri === undefined || (typeof mode.uri === 'object' && mode.uri !== null)) && (mode.source === undefined || isChatModeSourceData(mode.source)) && - (mode.target === undefined || typeof mode.target === 'string') && + (mode.target === undefined || isTarget(mode.target)) && (mode.visibility === undefined || isCustomAgentVisibility(mode.visibility)) && (mode.agents === undefined || Array.isArray(mode.agents)); } @@ -316,7 +316,7 @@ export class CustomChatMode implements IChatMode { private readonly _modelObservable: ISettableObservable; private readonly _argumentHintObservable: ISettableObservable; private readonly _handoffsObservable: ISettableObservable; - private readonly _targetObservable: ISettableObservable; + private readonly _targetObservable: ISettableObservable; private readonly _visibilityObservable: ISettableObservable; private readonly _agentsObservable: ISettableObservable; private _source: IAgentSource; @@ -371,7 +371,7 @@ export class CustomChatMode implements IChatMode { return this._source; } - get target(): IObservable { + get target(): IObservable { return this._targetObservable; } @@ -483,6 +483,7 @@ export class BuiltinChatMode implements IChatMode { public readonly label: IObservable; public readonly description: IObservable; public readonly icon: IObservable; + public readonly target: IObservable; constructor( public readonly kind: ChatModeKind, @@ -494,6 +495,7 @@ export class BuiltinChatMode implements IChatMode { this.label = constObservable(label); this.description = observableValue('description', description); this.icon = constObservable(icon); + this.target = constObservable(Target.Undefined); } public get isBuiltin(): boolean { @@ -505,10 +507,6 @@ export class BuiltinChatMode implements IChatMode { return this.kind; } - get target(): IObservable { - return observableValue('target', undefined); - } - /** * Getters are not json-stringified */ diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 45bfafb65646a..57ba35bde265d 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -15,6 +15,7 @@ import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from './participa import { IChatEditingSession } from './editing/chatEditingService.js'; import { IChatModel, IChatRequestVariableData, ISerializableChatModelInputState } from './model/chatModel.js'; import { IChatProgress, IChatService, IChatSessionTiming } from './chatService/chatService.js'; +import { Target } from './promptSyntax/service/promptsService.js'; export const enum ChatSessionStatus { Failed = 0, @@ -90,7 +91,7 @@ export interface IChatSessionsExtensionPoint { * to reuse the standard agent/mode dropdown with filtered custom agents. * Custom agents without a `target` property are also shown in all filtered lists */ - readonly customAgentTarget?: string; + readonly customAgentTarget?: Target; } export interface IChatSessionItem { @@ -259,9 +260,9 @@ export interface IChatSessionsService { /** * Get the customAgentTarget for a specific session type. - * When set, the mode picker should show filtered custom agents matching this target. + * When the Target is not `Target.Undefined`, the mode picker should show filtered custom agents matching this target. */ - getCustomAgentTargetForSessionType(chatSessionType: string): string | undefined; + getCustomAgentTargetForSessionType(chatSessionType: string): Target; onDidChangeOptionGroups: Event; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index 81f81c0d3d6fe..87524c6ce110e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -4,10 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../../base/common/uri.js'; -import { basename, dirname } from '../../../../../../base/common/path.js'; +import { posix } from '../../../../../../base/common/path.js'; import { PromptsType } from '../promptTypes.js'; import { PromptsStorage } from '../service/promptsService.js'; +const { basename, dirname } = posix; + /** * File extension for the reusable prompt files. */ @@ -48,6 +50,11 @@ export const CLAUDE_MD_FILENAME = 'CLAUDE.md'; */ export const CLAUDE_LOCAL_MD_FILENAME = 'CLAUDE.local.md'; +/** + * Claude configuration folder name. + */ +export const CLAUDE_CONFIG_FOLDER = '.claude'; + /** * Default hook file name (case insensitive). */ @@ -79,6 +86,11 @@ export const LEGACY_MODE_DEFAULT_SOURCE_FOLDER = '.github/chatmodes'; */ export const AGENTS_SOURCE_FOLDER = '.github/agents'; +/** + * Claude agents folder. + */ +export const CLAUDE_AGENTS_SOURCE_FOLDER = '.claude/agents'; + /** * Hooks folder. */ @@ -168,6 +180,7 @@ export const DEFAULT_PROMPT_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ */ export const DEFAULT_AGENT_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ { path: AGENTS_SOURCE_FOLDER, source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, + { path: CLAUDE_AGENTS_SOURCE_FOLDER, source: PromptFileSource.ClaudeWorkspace, storage: PromptsStorage.local }, ]; /** @@ -185,7 +198,7 @@ export const DEFAULT_HOOK_FILE_PATHS: readonly IPromptSourceFolder[] = [ */ function isInAgentsFolder(fileUri: URI): boolean { const dir = dirname(fileUri.path); - return dir.endsWith('/' + AGENTS_SOURCE_FOLDER) || dir === AGENTS_SOURCE_FOLDER; + return dir.endsWith('/' + AGENTS_SOURCE_FOLDER) || dir.endsWith('/' + CLAUDE_AGENTS_SOURCE_FOLDER); } /** @@ -224,11 +237,10 @@ export function getPromptFileType(fileUri: URI): PromptsType | undefined { // Check if it's a settings.local.json or settings.json file in a .claude folder if (filename.toLowerCase() === 'settings.local.json' || filename.toLowerCase() === 'settings.json') { const dir = dirname(fileUri.path); - if (dir.endsWith('/.claude') || dir === '.claude') { + if (basename(dir) === CLAUDE_CONFIG_FOLDER) { return PromptsType.hook; } } - return undefined; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts index f229164fb33e2..f89228a9fbde3 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts @@ -11,12 +11,12 @@ import { localize } from '../../../../../../nls.js'; import { ILanguageModelToolsService } from '../../tools/languageModelToolsService.js'; import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; -import { ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; +import { parseCommaSeparatedList, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; import { Selection } from '../../../../../../editor/common/core/selection.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { isGithubTarget, MARKERS_OWNER_ID } from './promptValidator.js'; +import { getTarget, isVSCodeOrDefaultTarget, MARKERS_OWNER_ID } from './promptValidator.js'; import { IMarkerData, IMarkerService } from '../../../../../../platform/markers/common/markers.js'; import { CodeActionKind } from '../../../../../../editor/contrib/codeAction/common/types.js'; @@ -107,15 +107,22 @@ export class PromptCodeActionProvider implements CodeActionProvider { private getUpdateToolsCodeActions(promptFile: ParsedPromptFile, promptType: PromptsType, model: ITextModel, range: Range, result: CodeAction[]): void { const toolsAttr = promptFile.header?.getAttribute(PromptHeaderAttributes.tools); - if (toolsAttr?.value.type !== 'array' || !toolsAttr.value.range.containsRange(range)) { + if (!toolsAttr || !toolsAttr.value.range.containsRange(range)) { return; } - if (isGithubTarget(promptType, promptFile.header?.target)) { - // GitHub Copilot custom agents use a fixed set of tool names that are not deprecated + const target = getTarget(promptType, promptFile.header); + if (!isVSCodeOrDefaultTarget(target)) { + // GitHub Copilot and Claude custom agents use a fixed set of tool names that are not deprecated return; } - - const values = toolsAttr.value.items; + let value = toolsAttr.value; + if (value.type === 'string') { + value = parseCommaSeparatedList(value); + } + if (value.type !== 'array') { + return; + } + const values = value.items; const deprecatedNames = new Lazy(() => this.languageModelToolsService.getDeprecatedFullReferenceNames()); const edits: TextEdit[] = []; for (const item of values) { @@ -164,7 +171,7 @@ export class PromptCodeActionProvider implements CodeActionProvider { if (edits.length && result.length === 0 || edits.length > 1) { result.push( - this.createCodeAction(model, toolsAttr.value.range, + this.createCodeAction(model, value.range, localize('updateAllToolNames', "Update all tool names"), edits.map(edit => asWorkspaceTextEdit(model, edit)) ) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptDocumentSemanticTokensProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptDocumentSemanticTokensProvider.ts index aa7474566aa07..24099d3d4925f 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptDocumentSemanticTokensProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptDocumentSemanticTokensProvider.ts @@ -8,7 +8,7 @@ import { DocumentSemanticTokensProvider, ProviderResult, SemanticTokens, Semanti import { ITextModel } from '../../../../../../editor/common/model.js'; import { getPromptsTypeForLanguageId } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; -import { isGithubTarget } from './promptValidator.js'; +import { getTarget, isVSCodeOrDefaultTarget } from './promptValidator.js'; export class PromptDocumentSemanticTokensProvider implements DocumentSemanticTokensProvider { /** @@ -32,9 +32,9 @@ export class PromptDocumentSemanticTokensProvider implements DocumentSemanticTok if (!promptAST.body) { return undefined; } - - if (isGithubTarget(promptType, promptAST.header?.target)) { - // In GitHub Copilot mode, we don't provide variable semantic tokens to tool references + const target = getTarget(promptType, promptAST.header); + if (!isVSCodeOrDefaultTarget(target)) { + // variables syntax is only support for VS Code and default targets, not for GitHub Copilot or Claude custom agents return undefined; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts index 56165a933ed32..ace94d619023f 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -13,11 +13,13 @@ import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../langua import { ILanguageModelToolsService } from '../../tools/languageModelToolsService.js'; import { IChatModeService } from '../../chatModes.js'; import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; -import { IPromptsService } from '../service/promptsService.js'; +import { IPromptsService, Target } from '../service/promptsService.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; -import { IHeaderAttribute, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; -import { getAttributeDescription, getValidAttributeNames, isGithubTarget, knownGithubCopilotTools } from './promptValidator.js'; +import { ClaudeHeaderAttributes, IArrayValue, IValue, parseCommaSeparatedList, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; +import { getAttributeDescription, getTarget, getValidAttributeNames, claudeAgentAttributes, knownClaudeTools, knownGithubCopilotTools, IValueEntry } from './promptValidator.js'; import { localize } from '../../../../../../nls.js'; +import { formatArrayValue, getQuotePreference } from '../utils/promptEditHelper.js'; + export class PromptHeaderAutocompletion implements CompletionItemProvider { /** @@ -106,8 +108,8 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { const suggestions: CompletionItem[] = []; - const isGitHubTarget = isGithubTarget(promptType, header.target); - const attributesToPropose = new Set(getValidAttributeNames(promptType, false, isGitHubTarget)); + const target = getTarget(promptType, header); + const attributesToPropose = new Set(getValidAttributeNames(promptType, false, target)); for (const attr of header.attributes) { attributesToPropose.delete(attr.key); } @@ -115,9 +117,9 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { if (colonPosition) { return key; } - const valueSuggestions = this.getValueSuggestions(promptType, key); + const valueSuggestions = this.getValueSuggestions(promptType, key, target); if (valueSuggestions.length > 0) { - return `${key}: \${0:${valueSuggestions[0]}}`; + return `${key}: \${0:${valueSuggestions[0].name}}`; } else { return `${key}: \$0`; } @@ -127,7 +129,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { for (const attribute of attributesToPropose) { const item: CompletionItem = { label: attribute, - documentation: getAttributeDescription(attribute, promptType), + documentation: getAttributeDescription(attribute, promptType, target), kind: CompletionItemKind.Property, insertText: getInsertText(attribute), insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet, @@ -146,15 +148,14 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { colonPosition: Position, promptType: PromptsType, ): Promise { - const suggestions: CompletionItem[] = []; - const attribute = header.attributes.find(attr => attr.range.containsPosition(position)); + const posLineNumber = position.lineNumber; + const attribute = header.attributes.find(({ range }) => range.startLineNumber <= posLineNumber && posLineNumber <= range.endLineNumber); if (!attribute) { return undefined; } - - const isGitHubTarget = isGithubTarget(promptType, header.target); - if (!getValidAttributeNames(promptType, true, isGitHubTarget).includes(attribute.key)) { + const target = getTarget(promptType, header); + if (!getValidAttributeNames(promptType, true, target).includes(attribute.key)) { return undefined; } @@ -162,38 +163,58 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { if (attribute.key === PromptHeaderAttributes.model) { if (attribute.value.type === 'array') { // if the position is inside the tools metadata, we provide tool name completions - const getValues = async () => this.getModelNames(promptType === PromptsType.agent); - return this.provideArrayCompletions(model, position, attribute, getValues); + const getValues = async () => { + if (target === Target.Claude) { + return knownClaudeTools; + } else { + return this.getModelNames(promptType === PromptsType.agent); + } + }; + return this.provideArrayCompletions(model, position, attribute.value, getValues); } } - if (attribute.key === PromptHeaderAttributes.tools) { - if (attribute.value.type === 'array') { + if (attribute.key === PromptHeaderAttributes.tools || attribute.key === ClaudeHeaderAttributes.disallowedTools) { + let value = attribute.value; + if (value.type === 'string') { + value = parseCommaSeparatedList(value); + } + if (value.type === 'array') { // if the position is inside the tools metadata, we provide tool name completions - const getValues = async () => isGitHubTarget ? knownGithubCopilotTools : Array.from(this.languageModelToolsService.getFullReferenceNames()); - return this.provideArrayCompletions(model, position, attribute, getValues); + const getValues = async () => { + if (target === Target.GitHubCopilot) { + // for GitHub Copilot agent files, we only suggest the known set of tools that are supported by GitHub Copilot, instead of all tools that the user has defined, because many tools won't work with GitHub Copilot and it would be frustrating for users to select a tool that doesn't work + return knownGithubCopilotTools; + } else if (target === Target.Claude) { + return knownClaudeTools; + } else { + return Array.from(this.languageModelToolsService.getFullReferenceNames()).map(name => ({ name })); + } + }; + return this.provideArrayCompletions(model, position, value, getValues); } } } - if (promptType === PromptsType.agent) { - if (attribute.key === PromptHeaderAttributes.agents && !isGitHubTarget) { - if (attribute.value.type === 'array') { - return this.provideArrayCompletions(model, position, attribute, async () => (await this.promptsService.getCustomAgents(CancellationToken.None)).map(agent => agent.name)); - } + if (attribute.key === PromptHeaderAttributes.agents) { + if (attribute.value.type === 'array') { + return this.provideArrayCompletions(model, position, attribute.value, async () => { + return await this.promptsService.getCustomAgents(CancellationToken.None); + }); } } const lineContent = model.getLineContent(attribute.range.startLineNumber); const whilespaceAfterColon = (lineContent.substring(colonPosition.column).match(/^\s*/)?.[0].length) ?? 0; - const values = this.getValueSuggestions(promptType, attribute.key); - for (const value of values) { + const entries = this.getValueSuggestions(promptType, attribute.key, target); + for (const entry of entries) { const item: CompletionItem = { - label: value, + label: entry.name, + documentation: entry.description, kind: CompletionItemKind.Value, - insertText: whilespaceAfterColon === 0 ? ` ${value}` : value, + insertText: whilespaceAfterColon === 0 ? ` ${entry.name}` : entry.name, range: new Range(position.lineNumber, colonPosition.column + whilespaceAfterColon + 1, position.lineNumber, model.getLineMaxColumn(position.lineNumber)), }; suggestions.push(item); } - if (attribute.key === PromptHeaderAttributes.handOffs && (promptType === PromptsType.agent)) { + if (attribute.key === PromptHeaderAttributes.handOffs) { const value = [ '', ' - label: Start Implementation', @@ -212,11 +233,19 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { return { suggestions }; } - private getValueSuggestions(promptType: string, attribute: string): string[] { + private getValueSuggestions(promptType: string, attribute: string, target: Target): IValueEntry[] { + if (target === Target.Claude) { + return claudeAgentAttributes[attribute]?.enums ?? []; + } switch (attribute) { case PromptHeaderAttributes.applyTo: if (promptType === PromptsType.instructions) { - return [`'**'`, `'**/*.ts, **/*.js'`, `'**/*.php'`, `'**/*.py'`]; + return [ + { name: `'**'` }, + { name: `'**/*.ts, **/*.js'` }, + { name: `'**/*.php'` }, + { name: `'**/*.py'` } + ]; } break; case PromptHeaderAttributes.agent: @@ -224,21 +253,24 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { if (promptType === PromptsType.prompt) { // Get all available agents (builtin + custom) const agents = this.chatModeService.getModes(); - const suggestions: string[] = []; + const suggestions: IValueEntry[] = []; for (const agent of Iterable.concat(agents.builtin, agents.custom)) { - suggestions.push(agent.name.get()); + suggestions.push({ name: agent.name.get(), description: agent.label.get() }); } return suggestions; } break; case PromptHeaderAttributes.target: if (promptType === PromptsType.agent) { - return ['vscode', 'github-copilot']; + return [{ name: 'vscode' }, { name: 'github-copilot' }]; } break; case PromptHeaderAttributes.tools: if (promptType === PromptsType.prompt || promptType === PromptsType.agent) { - return ['[]', `['search', 'edit', 'fetch']`]; + return [ + { name: '[]' }, + { name: `['search', 'edit', 'web']` } + ]; } break; case PromptHeaderAttributes.model: @@ -248,58 +280,68 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { break; case PromptHeaderAttributes.infer: if (promptType === PromptsType.agent) { - return ['true', 'false']; + return [ + { name: 'true' }, + { name: 'false' } + ]; } break; case PromptHeaderAttributes.agents: if (promptType === PromptsType.agent) { - return ['["*"]']; + return [{ name: '["*"]' }]; } break; case PromptHeaderAttributes.userInvokable: if (promptType === PromptsType.agent || promptType === PromptsType.skill) { - return ['true', 'false']; + return [{ name: 'true' }, { name: 'false' }]; } break; case PromptHeaderAttributes.disableModelInvocation: if (promptType === PromptsType.agent || promptType === PromptsType.skill) { - return ['true', 'false']; + return [{ name: 'true' }, { name: 'false' }]; } break; } return []; } - private getModelNames(agentModeOnly: boolean): string[] { + private getModelNames(agentModeOnly: boolean): IValueEntry[] { const result = []; for (const model of this.languageModelsService.getLanguageModelIds()) { const metadata = this.languageModelsService.lookupLanguageModel(model); if (metadata && metadata.isUserSelectable !== false) { if (!agentModeOnly || ILanguageModelChatMetadata.suitableForAgentMode(metadata)) { - result.push(ILanguageModelChatMetadata.asQualifiedName(metadata)); + result.push({ + name: ILanguageModelChatMetadata.asQualifiedName(metadata), + description: metadata.tooltip + }); } } } return result; } - private async provideArrayCompletions(model: ITextModel, position: Position, agentsAttr: IHeaderAttribute, getValues: () => Promise): Promise { - if (agentsAttr.value.type !== 'array') { - return undefined; - } - const getSuggestions = async (toolRange: Range) => { + private async provideArrayCompletions(model: ITextModel, position: Position, arrayValue: IArrayValue, getValues: () => Promise>): Promise { + const getSuggestions = async (toolRange: Range, currentItem?: IValue) => { const suggestions: CompletionItem[] = []; - const toolNames = await getValues(); - for (const toolName of toolNames) { + const entries = await getValues(); + const quotePreference = getQuotePreference(arrayValue, model); + const existingValues = new Set(arrayValue.items.filter(item => item !== currentItem).filter(item => item.type === 'string').map(item => item.value)); + for (const entry of entries) { + const entryName = entry.name; + if (existingValues.has(entryName)) { + continue; + } let insertText: string; if (!toolRange.isEmpty()) { const firstChar = model.getValueInRange(toolRange).charCodeAt(0); - insertText = firstChar === CharCode.SingleQuote ? `'${toolName}'` : firstChar === CharCode.DoubleQuote ? `"${toolName}"` : toolName; + insertText = firstChar === CharCode.SingleQuote ? `'${entryName}'` : firstChar === CharCode.DoubleQuote ? `"${entryName}"` : entryName; } else { - insertText = `'${toolName}'`; + insertText = formatArrayValue(entryName, quotePreference); } suggestions.push({ - label: toolName, + label: entryName, + documentation: entry.description, kind: CompletionItemKind.Value, filterText: insertText, insertText: insertText, @@ -309,18 +351,18 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { return { suggestions }; }; - for (const toolNameNode of agentsAttr.value.items) { - if (toolNameNode.range.containsPosition(position)) { - // if the position is inside a tool range, we provide tool name completions - return await getSuggestions(toolNameNode.range); + for (const item of arrayValue.items) { + if (item.range.containsPosition(position)) { + // if the position is inside a item range, we provide item completions + return await getSuggestions(item.range, item); } } const prefix = model.getValueInRange(new Range(position.lineNumber, 1, position.lineNumber, position.column)); - if (prefix.match(/[,[]\s*$/)) { + if (prefix.match(/[:,[]\s*$/)) { // if the position is after a comma or bracket return await getSuggestions(new Range(position.lineNumber, position.column, position.lineNumber, position.column)); } return undefined; - } + } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts index 07b7cfd336e3a..88b8aa56db896 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts @@ -14,9 +14,9 @@ import { ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, isToolSet, IToolSet } from '../../tools/languageModelToolsService.js'; import { IChatModeService, isBuiltinChatMode } from '../../chatModes.js'; import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; -import { IPromptsService } from '../service/promptsService.js'; -import { IHeaderAttribute, PromptBody, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; -import { getAttributeDescription, isGithubTarget } from './promptValidator.js'; +import { IPromptsService, Target } from '../service/promptsService.js'; +import { ClaudeHeaderAttributes, IHeaderAttribute, parseCommaSeparatedList, PromptBody, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; +import { getAttributeDescription, getTarget, isVSCodeOrDefaultTarget, knownClaudeModels, knownClaudeTools } from './promptValidator.js'; export class PromptHoverProvider implements HoverProvider { /** @@ -48,40 +48,44 @@ export class PromptHoverProvider implements HoverProvider { } const promptAST = this.promptsService.getParsedPromptFile(model); + const target = getTarget(promptType, promptAST.header); + if (promptAST.header?.range.containsPosition(position)) { - return this.provideHeaderHover(position, promptType, promptAST.header); + return this.provideHeaderHover(position, promptType, promptAST.header, target); } if (promptAST.body?.range.containsPosition(position)) { - return this.provideBodyHover(position, promptAST.body); + return this.provideBodyHover(position, promptAST.body, target); } return undefined; } - private async provideBodyHover(position: Position, body: PromptBody): Promise { + private async provideBodyHover(position: Position, body: PromptBody, target: Target): Promise { for (const ref of body.variableReferences) { if (ref.range.containsPosition(position)) { const toolName = ref.name; - return this.getToolHoverByName(toolName, ref.range); + + return this.getToolHoverByName(toolName, ref.range, target); } } return undefined; } - private async provideHeaderHover(position: Position, promptType: PromptsType, header: PromptHeader): Promise { + private async provideHeaderHover(position: Position, promptType: PromptsType, header: PromptHeader, target: Target): Promise { for (const attribute of header.attributes) { if (attribute.range.containsPosition(position)) { - const description = getAttributeDescription(attribute.key, promptType); + const description = getAttributeDescription(attribute.key, promptType, target); if (description) { switch (attribute.key) { case PromptHeaderAttributes.model: - return this.getModelHover(attribute, position, description, promptType === PromptsType.agent && isGithubTarget(promptType, header.target)); + return this.getModelHover(attribute, position, description, target); case PromptHeaderAttributes.tools: - return this.getToolHover(attribute, position, description); + case ClaudeHeaderAttributes.disallowedTools: + return this.getToolHover(attribute, position, description, target); case PromptHeaderAttributes.agent: case PromptHeaderAttributes.mode: return this.getAgentHover(attribute, position, description); case PromptHeaderAttributes.handOffs: - return this.getHandsOffHover(attribute, position, promptType === PromptsType.agent && isGithubTarget(promptType, header.target)); + return this.getHandsOffHover(attribute, position, target); case PromptHeaderAttributes.infer: return this.createHover(description + '\n\n' + localize('promptHeader.attribute.infer.hover', 'Deprecated: Use `user-invokable` and `disable-model-invocation` instead.'), attribute.range); default: @@ -93,11 +97,15 @@ export class PromptHoverProvider implements HoverProvider { return undefined; } - private getToolHover(node: IHeaderAttribute, position: Position, baseMessage: string): Hover | undefined { - if (node.value.type === 'array') { - for (const toolName of node.value.items) { + private getToolHover(node: IHeaderAttribute, position: Position, baseMessage: string, target: Target): Hover | undefined { + let value = node.value; + if (value.type === 'string') { + value = parseCommaSeparatedList(value); + } + if (value.type === 'array') { + for (const toolName of value.items) { if (toolName.type === 'string' && toolName.range.containsPosition(position)) { - const description = this.getToolHoverByName(toolName.value, toolName.range); + const description = this.getToolHoverByName(toolName.value, toolName.range, target); if (description) { return description; } @@ -107,7 +115,14 @@ export class PromptHoverProvider implements HoverProvider { return this.createHover(baseMessage, node.range); } - private getToolHoverByName(toolName: string, range: Range): Hover | undefined { + private getToolHoverByName(toolName: string, range: Range, target: Target): Hover | undefined { + if (target === Target.Claude) { + const description = knownClaudeTools.find(tool => tool.name === toolName)?.description; + if (description) { + return this.createHover(description, range); + } + return undefined; + } const tool = this.languageModelToolsService.getToolByFullReferenceName(toolName); if (tool !== undefined) { if (isToolSet(tool)) { @@ -131,16 +146,31 @@ export class PromptHoverProvider implements HoverProvider { return this.createHover(lines.join('\n'), range); } - private getModelHover(node: IHeaderAttribute, position: Position, baseMessage: string, isGitHubTarget: boolean): Hover | undefined { - if (isGitHubTarget) { + private getModelHover(node: IHeaderAttribute, position: Position, baseMessage: string, target: Target): Hover | undefined { + if (target === Target.GitHubCopilot) { return this.createHover(baseMessage + '\n\n' + localize('promptHeader.agent.model.githubCopilot', 'Note: This attribute is not used when target is github-copilot.'), node.range); } const modelHoverContent = (modelName: string): Hover | undefined => { + const lines: string[] = []; + lines.push(baseMessage + '\n'); + + if (target === Target.Claude) { + const claudeModel = knownClaudeModels.find(model => model.name === modelName); + if (!claudeModel) { + return this.createHover(lines.join('\n'), node.range); + } + if (claudeModel.modelEquivalent) { + lines.push(localize('claudeModelEquivalent', 'Claude model `{0}` maps to the following model:\n', modelName)); + modelName = claudeModel.modelEquivalent; + } else { + lines.push(claudeModel.description); + return this.createHover(lines.join('\n'), node.range); + } + } + const result = this.languageModelsService.lookupLanguageModelByQualifiedName(modelName); if (result) { const meta = result.metadata; - const lines: string[] = []; - lines.push(baseMessage + '\n'); lines.push(localize('modelName', '- Name: {0}', meta.name)); lines.push(localize('modelFamily', '- Family: {0}', meta.family)); lines.push(localize('modelVendor', '- Vendor: {0}', meta.vendor)); @@ -202,10 +232,10 @@ export class PromptHoverProvider implements HoverProvider { return this.createHover(lines.join('\n'), agentAttribute.range); } - private getHandsOffHover(attribute: IHeaderAttribute, position: Position, isGitHubTarget: boolean): Hover | undefined { - const handoffsBaseMessage = getAttributeDescription(PromptHeaderAttributes.handOffs, PromptsType.agent)!; - if (isGitHubTarget) { - return this.createHover(handoffsBaseMessage + '\n\n' + localize('promptHeader.agent.handoffs.githubCopilot', 'Note: This attribute is not used when target is github-copilot.'), attribute.range); + private getHandsOffHover(attribute: IHeaderAttribute, position: Position, target: Target): Hover | undefined { + const handoffsBaseMessage = getAttributeDescription(PromptHeaderAttributes.handOffs, PromptsType.agent, target)!; + if (!isVSCodeOrDefaultTarget(target)) { + return this.createHover(handoffsBaseMessage + '\n\n' + localize('promptHeader.agent.handoffs.githubCopilot', 'Note: This attribute is not used in GitHub Copilot or Claude targets.'), attribute.range); } return this.createHover(handoffsBaseMessage, attribute.range); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index 33d61b4a2329b..32625256b5682 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -16,16 +16,17 @@ import { ChatModeKind } from '../../constants.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, SpecedToolAliases } from '../../tools/languageModelToolsService.js'; import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; -import { GithubPromptHeaderAttributes, IArrayValue, IHeaderAttribute, IStringValue, ParsedPromptFile, PromptHeader, PromptHeaderAttributes, Target } from '../promptFileParser.js'; +import { GithubPromptHeaderAttributes, IArrayValue, IHeaderAttribute, IStringValue, parseCommaSeparatedList, ParsedPromptFile, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { IPromptsService } from '../service/promptsService.js'; +import { IPromptsService, Target } from '../service/promptsService.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; -import { AGENTS_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; +import { AGENTS_SOURCE_FOLDER, CLAUDE_AGENTS_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { dirname } from '../../../../../../base/common/resources.js'; export const MARKERS_OWNER_ID = 'prompts-diagnostics-provider'; @@ -41,8 +42,9 @@ export class PromptValidator { public async validate(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { promptAST.header?.errors.forEach(error => report(toMarker(error.message, error.range, MarkerSeverity.Error))); - await this.validateHeader(promptAST, promptType, report); - await this.validateBody(promptAST, promptType, report); + const target = getTarget(promptType, promptAST.header); + await this.validateHeader(promptAST, promptType, target, report); + await this.validateBody(promptAST, target, report); await this.validateFileName(promptAST, promptType, report); await this.validateSkillFolderName(promptAST, promptType, report); } @@ -88,7 +90,7 @@ export class PromptValidator { } } - private async validateBody(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { + private async validateBody(promptAST: ParsedPromptFile, target: Target, report: (markers: IMarkerData) => void): Promise { const body = promptAST.body; if (!body) { return; @@ -118,13 +120,10 @@ export class PromptValidator { } } - const isGitHubTarget = isGithubTarget(promptType, promptAST.header?.target); - // Validate variable references (tool or toolset names) - if (body.variableReferences.length && !isGitHubTarget) { + if (body.variableReferences.length && isVSCodeOrDefaultTarget(target)) { const headerTools = promptAST.header?.tools; - const headerTarget = promptAST.header?.target; - const headerToolsMap = headerTools ? this.languageModelToolsService.toToolAndToolSetEnablementMap(headerTools, headerTarget, undefined) : undefined; + const headerToolsMap = headerTools ? this.languageModelToolsService.toToolAndToolSetEnablementMap(headerTools, undefined) : undefined; const available = new Set(this.languageModelToolsService.getFullReferenceNames()); const deprecatedNames = this.languageModelToolsService.getDeprecatedFullReferenceNames(); @@ -156,22 +155,21 @@ export class PromptValidator { await Promise.all(fileReferenceChecks); } - private async validateHeader(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { + private async validateHeader(promptAST: ParsedPromptFile, promptType: PromptsType, target: Target, report: (markers: IMarkerData) => void): Promise { const header = promptAST.header; if (!header) { return; } const attributes = header.attributes; - const isGitHubTarget = isGithubTarget(promptType, header.target); - this.checkForInvalidArguments(attributes, promptType, isGitHubTarget, report); + this.checkForInvalidArguments(attributes, promptType, target, report); - this.validateName(attributes, isGitHubTarget, report); + this.validateName(attributes, report); this.validateDescription(attributes, report); this.validateArgumentHint(attributes, report); switch (promptType) { case PromptsType.prompt: { const agent = this.validateAgent(attributes, report); - this.validateTools(attributes, agent?.kind ?? ChatModeKind.Agent, header.target, report); + this.validateTools(attributes, agent?.kind ?? ChatModeKind.Agent, target, report); this.validateModel(attributes, agent?.kind ?? ChatModeKind.Agent, report); break; } @@ -185,11 +183,13 @@ export class PromptValidator { this.validateInfer(attributes, report); this.validateUserInvokable(attributes, report); this.validateDisableModelInvocation(attributes, report); - this.validateTools(attributes, ChatModeKind.Agent, header.target, report); - if (!isGitHubTarget) { + this.validateTools(attributes, ChatModeKind.Agent, target, report); + if (isVSCodeOrDefaultTarget(target)) { this.validateModel(attributes, ChatModeKind.Agent, report); this.validateHandoffs(attributes, report); await this.validateAgentsAttribute(attributes, header, report); + } else if (target === Target.Claude) { + this.validateClaudeAttributes(attributes, report); } break; } @@ -198,23 +198,24 @@ export class PromptValidator { this.validateUserInvokable(attributes, report); this.validateDisableModelInvocation(attributes, report); break; - } } - private checkForInvalidArguments(attributes: IHeaderAttribute[], promptType: PromptsType, isGitHubTarget: boolean, report: (markers: IMarkerData) => void): void { - const validAttributeNames = getValidAttributeNames(promptType, true, isGitHubTarget); - const validGithubCopilotAttributeNames = new Lazy(() => new Set(getValidAttributeNames(promptType, false, true))); + private checkForInvalidArguments(attributes: IHeaderAttribute[], promptType: PromptsType, target: Target, report: (markers: IMarkerData) => void): void { + const validAttributeNames = getValidAttributeNames(promptType, true, target); + const validGithubCopilotAttributeNames = new Lazy(() => new Set(getValidAttributeNames(promptType, false, Target.GitHubCopilot))); for (const attribute of attributes) { if (!validAttributeNames.includes(attribute.key)) { - const supportedNames = new Lazy(() => getValidAttributeNames(promptType, false, isGitHubTarget).sort().join(', ')); + const supportedNames = new Lazy(() => getValidAttributeNames(promptType, false, target).sort().join(', ')); switch (promptType) { case PromptsType.prompt: report(toMarker(localize('promptValidator.unknownAttribute.prompt', "Attribute '{0}' is not supported in prompt files. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Warning)); break; case PromptsType.agent: - if (isGitHubTarget) { + if (target === Target.GitHubCopilot) { report(toMarker(localize('promptValidator.unknownAttribute.github-agent', "Attribute '{0}' is not supported in custom GitHub Copilot agent files. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Warning)); + } else if (target === Target.Claude) { + // ignore for now as we don't have a full list of supported attributes for claude target } else { if (validGithubCopilotAttributeNames.value.has(attribute.key)) { report(toMarker(localize('promptValidator.ignoredAttribute.vscode-agent', "Attribute '{0}' is ignored when running locally in VS Code.", attribute.key), attribute.range, MarkerSeverity.Info)); @@ -236,7 +237,7 @@ export class PromptValidator { - private validateName(attributes: IHeaderAttribute[], isGitHubTarget: boolean, report: (markers: IMarkerData) => void): void { + private validateName(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): void { const nameAttribute = attributes.find(attr => attr.key === PromptHeaderAttributes.name); if (!nameAttribute) { return; @@ -334,6 +335,30 @@ export class PromptValidator { } } + private validateClaudeAttributes(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): void { + // vaidate all claude-specific attributes that have enum values + for (const claudeAttributeName in claudeAgentAttributes) { + const claudeAttribute = claudeAgentAttributes[claudeAttributeName]; + const enumValues = claudeAttribute.enums; + if (enumValues) { + const attribute = attributes.find(attr => attr.key === claudeAttributeName); + if (!attribute) { + continue; + } + if (attribute.value.type !== 'string') { + report(toMarker(localize('promptValidator.claude.attributeMustBeString', "The '{0}' attribute must be a string.", claudeAttributeName), attribute.value.range, MarkerSeverity.Error)); + continue; + } else { + const modelName = attribute.value.value.trim(); + if (enumValues.every(model => model.name !== modelName)) { + const validValues = enumValues.map(model => model.name).join(', '); + report(toMarker(localize('promptValidator.claude.attributeNotFound', "Unknown value '{0}', valid: {1}.", modelName, validValues), attribute.value.range, MarkerSeverity.Warning)); + } + } + } + } + } + private findModelByName(modelName: string): ILanguageModelChatMetadata | undefined { const metadataAndId = this.languageModelsService.lookupLanguageModelByQualifiedName(modelName); if (metadataAndId && metadataAndId.metadata.isUserSelectable !== false) { @@ -386,7 +411,7 @@ export class PromptValidator { return undefined; } - private validateTools(attributes: IHeaderAttribute[], agentKind: ChatModeKind, target: string | undefined, report: (markers: IMarkerData) => void): undefined { + private validateTools(attributes: IHeaderAttribute[], agentKind: ChatModeKind, target: Target, report: (markers: IMarkerData) => void): undefined { const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.tools); if (!attribute) { return; @@ -394,21 +419,22 @@ export class PromptValidator { if (agentKind !== ChatModeKind.Agent) { report(toMarker(localize('promptValidator.toolsOnlyInAgent', "The 'tools' attribute is only supported when using agents. Attribute will be ignored."), attribute.range, MarkerSeverity.Warning)); } - - switch (attribute.value.type) { - case 'array': - if (target === Target.GitHubCopilot) { - // no validation for github-copilot target - } else { - this.validateVSCodeTools(attribute.value, target, report); - } - break; - default: - report(toMarker(localize('promptValidator.toolsMustBeArrayOrMap', "The 'tools' attribute must be an array."), attribute.value.range, MarkerSeverity.Error)); + let value = attribute.value; + if (value.type === 'string') { + value = parseCommaSeparatedList(value); + } + if (value.type !== 'array') { + report(toMarker(localize('promptValidator.toolsMustBeArrayOrMap', "The 'tools' attribute must be an array or a comma separated string."), attribute.value.range, MarkerSeverity.Error)); + return; + } + if (target === Target.GitHubCopilot || target === Target.Claude) { + // no validation for github-copilot target and claude + } else { + this.validateVSCodeTools(value, report); } } - private validateVSCodeTools(valueItem: IArrayValue, target: string | undefined, report: (markers: IMarkerData) => void) { + private validateVSCodeTools(valueItem: IArrayValue, report: (markers: IMarkerData) => void) { if (valueItem.items.length > 0) { const available = new Set(this.languageModelToolsService.getFullReferenceNames()); const deprecatedNames = this.languageModelToolsService.getDeprecatedFullReferenceNames(); @@ -638,9 +664,13 @@ const recommendedAttributeNames: Record = { [PromptsType.hook]: [], // hooks are JSON files, not markdown with YAML frontmatter }; -export function getValidAttributeNames(promptType: PromptsType, includeNonRecommended: boolean, isGitHubTarget: boolean): string[] { - if (isGitHubTarget && promptType === PromptsType.agent) { - return githubCopilotAgentAttributeNames; +export function getValidAttributeNames(promptType: PromptsType, includeNonRecommended: boolean, target: Target): string[] { + if (target === Target.Claude) { + return Object.keys(claudeAgentAttributes); + } else if (target === Target.GitHubCopilot) { + if (promptType === PromptsType.agent) { + return githubCopilotAgentAttributeNames; + } } return includeNonRecommended ? allAttributeNames[promptType] : recommendedAttributeNames[promptType]; } @@ -649,7 +679,10 @@ export function isNonRecommendedAttribute(attributeName: string): boolean { return attributeName === PromptHeaderAttributes.advancedOptions || attributeName === PromptHeaderAttributes.excludeAgent || attributeName === PromptHeaderAttributes.mode || attributeName === PromptHeaderAttributes.infer; } -export function getAttributeDescription(attributeName: string, promptType: PromptsType): string | undefined { +export function getAttributeDescription(attributeName: string, promptType: PromptsType, target: Target): string | undefined { + if (target === Target.Claude) { + return claudeAgentAttributes[attributeName]?.description; + } switch (promptType) { case PromptsType.instructions: switch (attributeName) { @@ -724,11 +757,147 @@ export function getAttributeDescription(attributeName: string, promptType: Promp // The list of tools known to be used by GitHub Copilot custom agents export const knownGithubCopilotTools = [ - SpecedToolAliases.execute, SpecedToolAliases.read, SpecedToolAliases.edit, SpecedToolAliases.search, SpecedToolAliases.agent, + { name: SpecedToolAliases.execute, description: localize('githubCopilot.execute', 'Execute commands') }, + { name: SpecedToolAliases.read, description: localize('githubCopilot.read', 'Read files') }, + { name: SpecedToolAliases.edit, description: localize('githubCopilot.edit', 'Edit files') }, + { name: SpecedToolAliases.search, description: localize('githubCopilot.search', 'Search files') }, + { name: SpecedToolAliases.agent, description: localize('githubCopilot.agent', 'Use subagents') }, ]; -export function isGithubTarget(promptType: PromptsType, target: string | undefined): boolean { - return promptType === PromptsType.agent && target === Target.GitHubCopilot; +export interface IValueEntry { + readonly name: string; + readonly description?: string; +} + +export const knownClaudeTools = [ + { name: 'Bash', description: localize('claude.bash', 'Execute shell commands'), toolEquivalent: [SpecedToolAliases.execute] }, + { name: 'Edit', description: localize('claude.edit', 'Make targeted file edits'), toolEquivalent: ['edit/editNotebook', 'edit/editFiles'] }, + { name: 'Glob', description: localize('claude.glob', 'Find files by pattern'), toolEquivalent: ['search/fileSearch'] }, + { name: 'Grep', description: localize('claude.grep', 'Search file contents with regex'), toolEquivalent: ['search/textSearch'] }, + { name: 'Read', description: localize('claude.read', 'Read file contents'), toolEquivalent: ['read/readFile', 'read/getNotebookSummary'] }, + { name: 'Write', description: localize('claude.write', 'Create/overwrite files'), toolEquivalent: ['edit/createDirectory', 'edit/createFile', 'edit/createJupyterNotebook'] }, + { name: 'WebFetch', description: localize('claude.webFetch', 'Fetch URL content'), toolEquivalent: [SpecedToolAliases.web] }, + { name: 'WebSearch', description: localize('claude.webSearch', 'Perform web searches'), toolEquivalent: [SpecedToolAliases.web] }, + { name: 'Task', description: localize('claude.task', 'Run subagents for complex tasks'), toolEquivalent: [SpecedToolAliases.agent] }, + { name: 'Skill', description: localize('claude.skill', 'Execute skills'), toolEquivalent: [] }, + { name: 'LSP', description: localize('claude.lsp', 'Code intelligence (requires plugin)'), toolEquivalent: [] }, + { name: 'NotebookEdit', description: localize('claude.notebookEdit', 'Modify Jupyter notebooks'), toolEquivalent: ['edit/editNotebook'] }, + { name: 'AskUserQuestion', description: localize('claude.askUserQuestion', 'Ask multiple-choice questions'), toolEquivalent: ['vscode/askQuestions'] }, + { name: 'MCPSearch', description: localize('claude.mcpSearch', 'Searches for MCP tools when tool search is enabled'), toolEquivalent: [] } +]; + +export const knownClaudeModels = [ + { name: 'sonnet', description: localize('claude.sonnet', 'Latest Claude Sonnet'), modelEquivalent: 'Claude Sonnet 4.5 (copilot)' }, + { name: 'opus', description: localize('claude.opus', 'Latest Claude Opus'), modelEquivalent: 'Claude Opus 4.6 (copilot)' }, + { name: 'haiku', description: localize('claude.haiku', 'Latest Claude Haiku, fast for simple tasks'), modelEquivalent: 'Claude Haiku 4.5 (copilot)' }, + { name: 'inherit', description: localize('claude.inherit', 'Inherit model from parent agent or prompt'), modelEquivalent: undefined }, +]; + +export function mapClaudeModels(claudeModelNames: readonly string[]): readonly string[] { + const result = []; + for (const name of claudeModelNames) { + const claudeModel = knownClaudeModels.find(model => model.name === name); + if (claudeModel && claudeModel.modelEquivalent) { + result.push(claudeModel.modelEquivalent); + } + } + return result; +} + +/** + * Maps Claude tool names to their VS Code tool equivalents. + */ +export function mapClaudeTools(claudeToolNames: readonly string[]): string[] { + const result: string[] = []; + for (const name of claudeToolNames) { + const claudeTool = knownClaudeTools.find(tool => tool.name === name); + if (claudeTool) { + result.push(...claudeTool.toolEquivalent); + } + } + return result; +} + +export const claudeAgentAttributes: Record = { + 'name': { + type: 'string', + description: localize('attribute.name', "Unique identifier using lowercase letters and hyphens (required)"), + }, + 'description': { + type: 'string', + description: localize('attribute.description', "When to delegate to this subagent (required)"), + }, + 'tools': { + type: 'array', + description: localize('attribute.tools', "Array of tools the subagent can use. Inherits all tools if omitted"), + defaults: ['Read, Edit, Bash'], + items: knownClaudeTools + }, + 'disallowedTools': { + type: 'array', + description: localize('attribute.disallowedTools', "Tools to deny, removed from inherited or specified list"), + defaults: ['Write, Edit, Bash'], + items: knownClaudeTools + }, + 'model': { + type: 'string', + description: localize('attribute.model', "Model to use: sonnet, opus, haiku, or inherit. Defaults to inherit."), + defaults: ['sonnet', 'opus', 'haiku', 'inherit'], + enums: knownClaudeModels + }, + 'permissionMode': { + type: 'string', + description: localize('attribute.permissionMode', "Permission mode: default, acceptEdits, dontAsk, bypassPermissions, or plan."), + defaults: ['default', 'acceptEdits', 'dontAsk', 'bypassPermissions', 'plan'], + enums: [ + { name: 'default', description: localize('claude.permissionMode.default', 'Standard behavior: prompts for permission on first use of each tool.') }, + { name: 'acceptEdits', description: localize('claude.permissionMode.acceptEdits', 'Automatically accepts file edit permissions for the session.') }, + { name: 'plan', description: localize('claude.permissionMode.plan', 'Plan Mode: Claude can analyze but not modify files or execute commands.') }, + { name: 'delegate', description: localize('claude.permissionMode.delegate', 'Coordination-only mode for agent team leads. Only available when an agent team is active.') }, + { name: 'dontAsk', description: localize('claude.permissionMode.dontAsk', 'Auto-denies tools unless pre-approved via /permissions or permissions.allow rules.') }, + { name: 'bypassPermissions', description: localize('claude.permissionMode.bypassPermissions', 'Skips all permission prompts (requires safe environment like containers).') } + ] + }, + 'skills': { + type: 'array', + description: localize('attribute.skills', "Skills to load into the subagent's context at startup."), + }, + 'mcpServers': { + type: 'array', + description: localize('attribute.mcpServers', "MCP servers available to this subagent."), + }, + 'hooks': { + type: 'object', + description: localize('attribute.hooks', "Lifecycle hooks scoped to this subagent."), + }, + 'memory': { + type: 'string', + description: localize('attribute.memory', "Persistent memory scope: user, project, or local. Enables cross-session learning."), + defaults: ['user', 'project', 'local'], + enums: [ + { name: 'user', description: localize('claude.memory.user', "Remember learnings across all projects.") }, + { name: 'project', description: localize('claude.memory.project', "The subagent's knowledge is project-specific and shareable via version control.") }, + { name: 'local', description: localize('claude.memory.local', "The subagent's knowledge is project-specific but should not be checked into version control.") } + ] + } +}; + +export function isVSCodeOrDefaultTarget(target: Target): boolean { + return target === Target.VSCode || target === Target.Undefined; +} + +export function getTarget(promptType: PromptsType, header: PromptHeader | undefined): Target { + if (header && promptType === PromptsType.agent) { + const parentDir = dirname(header.uri); + if (parentDir.path.endsWith(`/${CLAUDE_AGENTS_SOURCE_FOLDER}`)) { + return Target.Claude; + } + const target = header.target; + if (target === Target.GitHubCopilot || target === Target.VSCode) { + return target; + } + } + return Target.Undefined; } function toMarker(message: string, range: Range, severity = MarkerSeverity.Error): IMarkerData { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts index ef5406557e235..de13d5f8c8f91 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts @@ -9,6 +9,7 @@ import { splitLinesIncludeSeparators } from '../../../../../base/common/strings. import { URI } from '../../../../../base/common/uri.js'; import { parse, YamlNode, YamlParseError, Position as YamlPosition } from '../../../../../base/common/yaml.js'; import { Range } from '../../../../../editor/common/core/range.js'; +import { Target } from './service/promptsService.js'; export class PromptFileParser { constructor() { @@ -32,7 +33,7 @@ export class PromptFileParser { } // range starts on the line after the ---, and ends at the beginning of the line that has the closing --- const range = new Range(2, 1, headerEndLine + 1, 1); - header = new PromptHeader(range, linesWithEOL); + header = new PromptHeader(range, uri, linesWithEOL); } if (bodyStartLine < linesWithEOL.length) { // range starts on the line after the ---, and ends at the beginning of line after the last line @@ -87,15 +88,18 @@ export namespace GithubPromptHeaderAttributes { export const mcpServers = 'mcp-servers'; } -export enum Target { - VSCode = 'vscode', - GitHubCopilot = 'github-copilot' +export namespace ClaudeHeaderAttributes { + export const disallowedTools = 'disallowedTools'; +} + +export function isTarget(value: unknown): value is Target { + return value === Target.VSCode || value === Target.GitHubCopilot || value === Target.Claude || value === Target.Undefined; } export class PromptHeader { private _parsed: ParsedHeader | undefined; - constructor(public readonly range: Range, private readonly linesWithEOL: string[]) { + constructor(public readonly range: Range, public readonly uri: URI, private readonly linesWithEOL: string[]) { } private get _parsedHeader(): ParsedHeader { @@ -207,25 +211,18 @@ export class PromptHeader { if (!toolsAttribute) { return undefined; } - if (toolsAttribute.value.type === 'array') { + let value = toolsAttribute.value; + if (value.type === 'string') { + value = parseCommaSeparatedList(value); + } + if (value.type === 'array') { const tools: string[] = []; - for (const item of toolsAttribute.value.items) { + for (const item of value.items) { if (item.type === 'string' && item.value) { tools.push(item.value); } } return tools; - } else if (toolsAttribute.value.type === 'object') { - const tools: string[] = []; - const collectLeafs = ({ key, value }: { key: IStringValue; value: IValue }) => { - if (value.type === 'boolean') { - tools.push(key.value); - } else if (value.type === 'object') { - value.properties.forEach(collectLeafs); - } - }; - toolsAttribute.value.properties.forEach(collectLeafs); - return tools; } return undefined; } @@ -477,3 +474,76 @@ export interface IBodyVariableReference { readonly range: Range; readonly offset: number; } + +/** + * Parses a comma-separated list of values into an array of strings. + * Values can be unquoted or quoted (single or double quotes). + * + * @param input A string containing comma-separated values + * @returns An IArrayValue containing the parsed values and their ranges + */ +export function parseCommaSeparatedList(stringValue: IStringValue): IArrayValue { + const result: IStringValue[] = []; + const input = stringValue.value; + const positionOffset = stringValue.range.getStartPosition(); + let pos = 0; + const isWhitespace = (char: string): boolean => char === ' ' || char === '\t'; + + while (pos < input.length) { + // Skip leading whitespace + while (pos < input.length && isWhitespace(input[pos])) { + pos++; + } + + if (pos >= input.length) { + break; + } + + const startPos = pos; + let value = ''; + let endPos: number; + + const char = input[pos]; + if (char === '"' || char === `'`) { + // Quoted string + const quote = char; + pos++; // Skip opening quote + + while (pos < input.length && input[pos] !== quote) { + value += input[pos]; + pos++; + } + endPos = pos + 1; // Include closing quote in the range + + if (pos < input.length) { + pos++; + } + + } else { + // Unquoted string - read until comma or end + const startPos = pos; + while (pos < input.length && input[pos] !== ',') { + value += input[pos]; + pos++; + } + value = value.trimEnd(); + endPos = startPos + value.length; + } + + result.push({ type: 'string', value: value, range: new Range(positionOffset.lineNumber, positionOffset.column + startPos, positionOffset.lineNumber, positionOffset.column + endPos) }); + + // Skip whitespace after value + while (pos < input.length && isWhitespace(input[pos])) { + pos++; + } + + // Skip comma if present + if (pos < input.length && input[pos] === ',') { + pos++; + } + } + + return { type: 'array', items: result, range: stringValue.range }; +} + + 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 d5f9eb6b4832c..47b3e64e180c0 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -137,6 +137,13 @@ export function isCustomAgentVisibility(obj: unknown): obj is ICustomAgentVisibi return typeof v.userInvokable === 'boolean' && typeof v.agentInvokable === 'boolean'; } +export enum Target { + VSCode = 'vscode', + GitHubCopilot = 'github-copilot', + Claude = 'claude', + Undefined = 'undefined', +} + export interface ICustomAgent { /** * URI of a custom agent file. @@ -169,9 +176,9 @@ export interface ICustomAgent { readonly argumentHint?: string; /** - * Target metadata in the prompt header. + * Target of the agent: Copilot, VSCode, Claude, or undefined if not specified. */ - readonly target?: string; + readonly target: Target; /** * What visibility the agent has (user invokable, subagent invokable). 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 be1e0216699ce..50051e8321857 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -27,17 +27,18 @@ import { ITelemetryService } from '../../../../../../platform/telemetry/common/t import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; import { IVariableReference } from '../../chatModes.js'; import { PromptsConfig } from '../config/config.js'; -import { AGENT_MD_FILENAME, CLAUDE_LOCAL_MD_FILENAME, CLAUDE_MD_FILENAME, getCleanPromptName, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; +import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAUDE_MD_FILENAME, getCleanPromptName, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; import { PROMPT_LANGUAGE_ID, PromptsType, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; -import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger } from './promptsService.js'; +import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, Target } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { IChatRequestHooks, IHookCommand, HookType } from '../hookSchema.js'; import { parseHooksFromFile } from '../hookCompatibility.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; +import { getTarget, mapClaudeModels, mapClaudeTools } from '../languageProviders/promptValidator.js'; /** * Error thrown when a skill file is missing the required name attribute. @@ -566,17 +567,25 @@ export class PromptsService extends Disposable implements IPromptsService { } satisfies IAgentInstructions; const name = ast.header?.name ?? promptPath.name ?? getCleanPromptName(uri); + const target = getTarget(PromptsType.agent, ast.header); const source: IAgentSource = IAgentSource.fromPromptPath(promptPath); if (!ast.header) { - return { uri, name, agentInstructions, source, visibility: { userInvokable: true, agentInvokable: true } }; + return { uri, name, agentInstructions, source, target, visibility: { userInvokable: true, agentInvokable: true } }; } const visibility = { userInvokable: ast.header.userInvokable !== false, agentInvokable: ast.header.infer === true || ast.header.disableModelInvocation !== true, } satisfies ICustomAgentVisibility; - const { description, model, tools, handOffs, argumentHint, target, agents } = ast.header; + let model = ast.header.model; + if (target === Target.Claude && model) { + model = mapClaudeModels(model); + } + let { description, 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 }; }) ); @@ -709,11 +718,11 @@ export class PromptsService extends Disposable implements IPromptsService { } const results: IResolvedAgentFile[] = []; const userHome = await this.pathService.userHome(); - const userClaudeFolder = joinPath(userHome, '.claude'); + const userClaudeFolder = joinPath(userHome, CLAUDE_CONFIG_FOLDER); await Promise.all([ this.fileLocator.findFilesInWorkspaceRoots(CLAUDE_MD_FILENAME, undefined, AgentFileType.claudeMd, token, results), // in workspace roots this.fileLocator.findFilesInWorkspaceRoots(CLAUDE_LOCAL_MD_FILENAME, undefined, AgentFileType.claudeMd, token, results), // CLAUDE.local in workspace roots - this.fileLocator.findFilesInWorkspaceRoots(CLAUDE_MD_FILENAME, '.claude', AgentFileType.claudeMd, token, results), // in workspace/.claude folders + this.fileLocator.findFilesInWorkspaceRoots(CLAUDE_MD_FILENAME, CLAUDE_CONFIG_FOLDER, AgentFileType.claudeMd, token, results), // in workspace/.claude folders this.fileLocator.findFilesInRoots([userClaudeFolder], CLAUDE_MD_FILENAME, AgentFileType.claudeMd, token, results) // in ~/.claude folder ]); return results.sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptEditHelper.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptEditHelper.ts new file mode 100644 index 0000000000000..ba398b6754a30 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptEditHelper.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ITextModel } from '../../../../../../editor/common/model.js'; +import { IArrayValue } from '../promptFileParser.js'; + +const isSimpleNameRegex = /^[\w\/\.-]+$/; + +export function formatArrayValue(name: string, quotePreference?: QuotePreference) { + switch (quotePreference) { + case '\'': + return `'${name}'`; + case '"': + return `"${name}"`; + } + return isSimpleNameRegex.test(name) ? name : `'${name}'`; +} + +export type QuotePreference = '\'' | '\"' | ''; + +export function getQuotePreference(arrayValue: IArrayValue, model: ITextModel): QuotePreference { + const firstStringItem = arrayValue.items.find(item => item.type === 'string' && isSimpleNameRegex.test(item.value)); + const firstChar = firstStringItem ? model.getValueInRange(firstStringItem.range).charAt(0) : undefined; + if (firstChar === `'` || firstChar === `"`) { + return firstChar; + } + return ''; +} diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 5e2a33a7e3d2d..d1f19a0f152af 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -169,7 +169,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { const modeCustomTools = subagent.tools; if (modeCustomTools) { // Convert the mode's custom tools (array of qualified names) to UserSelectedTools format - const enablementMap = this.languageModelToolsService.toToolAndToolSetEnablementMap(modeCustomTools, subagent.target, undefined); + const enablementMap = this.languageModelToolsService.toToolAndToolSetEnablementMap(modeCustomTools, undefined); // Convert enablement map to UserSelectedTools (Record) modeTools = {}; for (const [tool, enabled] of enablementMap) { diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index e7a26991320fa..05a5d1d17524a 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -573,15 +573,10 @@ export interface ILanguageModelToolsService { /** * Gets the enablement maps based on the given set of references. * @param fullReferenceNames The full reference names of the tools and tool sets to enable. - * @param target Optional target to filter tools by. * @param model Optional language model metadata to filter tools by. * If undefined is passed, all tools will be returned, even if normally disabled. */ - toToolAndToolSetEnablementMap( - fullReferenceNames: readonly string[], - target: string | undefined, - model: ILanguageModelChatMetadata | undefined, - ): IToolAndToolSetEnablementMap; + toToolAndToolSetEnablementMap(fullReferenceNames: readonly string[], model: ILanguageModelChatMetadata | undefined): IToolAndToolSetEnablementMap; toFullReferenceNames(map: IToolAndToolSetEnablementMap): string[]; toToolReferences(variableReferences: readonly IVariableReference[]): ChatRequestToolReferenceEntry[]; diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts index cd2fa76b689b4..be6bae4cabbcd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts @@ -19,7 +19,7 @@ import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../../ import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../common/languageModels.js'; import { IChatModeService } from '../../../../common/chatModes.js'; import { PromptHeaderAutocompletion } from '../../../../common/promptSyntax/languageProviders/promptHeaderAutocompletion.js'; -import { ICustomAgent, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ICustomAgent, IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; import { createTextModel } from '../../../../../../../editor/test/common/testTextModel.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; @@ -75,6 +75,7 @@ suite('PromptHeaderAutocompletion', () => { }, uri: URI.parse('myFs://.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local }, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; @@ -97,9 +98,9 @@ suite('PromptHeaderAutocompletion', () => { completionProvider = instaService.createInstance(PromptHeaderAutocompletion); }); - async function getCompletions(content: string, promptType: PromptsType) { + async function getCompletions(content: string, promptType: PromptsType, uri?: URI) { const languageId = getLanguageIdForPromptsType(promptType); - const uri = URI.parse('test:///test' + getPromptFileExtension(promptType)); + uri ??= URI.parse('test:///test' + getPromptFileExtension(promptType)); const model = disposables.add(createTextModel(content, languageId, undefined, uri)); // get the completion location from the '|' marker const lineColumnMarkerRange = model.findNextMatch('|', new Position(1, 1), false, false, '', false)?.range; @@ -207,7 +208,6 @@ suite('PromptHeaderAutocompletion', () => { const actual = await getCompletions(content, PromptsType.agent); // GPT 4 is excluded because it has agentMode: false assert.deepStrictEqual(actual.sort(sortByLabel), [ - { label: 'MAE 4 (olama)', result: `model: ['MAE 4 (olama)', 'MAE 4 (olama)']` }, { label: 'MAE 4.1 (copilot)', result: `model: ['MAE 4 (olama)', 'MAE 4.1 (copilot)']` }, ].sort(sortByLabel)); }); @@ -222,16 +222,16 @@ suite('PromptHeaderAutocompletion', () => { const actual = await getCompletions(content, PromptsType.agent); assert.deepStrictEqual(actual.sort(sortByLabel), [ - { label: 'agent', result: `tools: ['agent']` }, - { label: 'execute', result: `tools: ['execute']` }, - { label: 'read', result: `tools: ['read']` }, - { label: 'tool1', result: `tools: ['tool1']` }, - { label: 'tool2', result: `tools: ['tool2']` }, - { label: 'vscode', result: `tools: ['vscode']` }, + { label: 'agent', result: `tools: [agent]` }, + { label: 'execute', result: `tools: [execute]` }, + { label: 'read', result: `tools: [read]` }, + { label: 'tool1', result: `tools: [tool1]` }, + { label: 'tool2', result: `tools: [tool2]` }, + { label: 'vscode', result: `tools: [vscode]` }, ].sort(sortByLabel)); }); - test('complete tool names inside tools array with existing entries', async () => { + test('complete tool names inside tools array with existing single quoted entries', async () => { const content = [ '---', 'description: "Test"', @@ -243,13 +243,48 @@ suite('PromptHeaderAutocompletion', () => { assert.deepStrictEqual(actual.sort(sortByLabel), [ { label: 'agent', result: `tools: ['read', 'agent']` }, { label: 'execute', result: `tools: ['read', 'execute']` }, - { label: 'read', result: `tools: ['read', 'read']` }, { label: 'tool1', result: `tools: ['read', 'tool1']` }, { label: 'tool2', result: `tools: ['read', 'tool2']` }, { label: 'vscode', result: `tools: ['read', 'vscode']` }, ].sort(sortByLabel)); }); + test('complete tool names inside tools array with existing double quoted entries', async () => { + const content = [ + '---', + 'description: "Test"', + `tools: ["read", "tool1", |]`, + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + assert.deepStrictEqual(actual.sort(sortByLabel), [ + { label: 'agent', result: `tools: ["read", "tool1", "agent"]` }, + { label: 'execute', result: `tools: ["read", "tool1", "execute"]` }, + { label: 'tool2', result: `tools: ["read", "tool1", "tool2"]` }, + { label: 'vscode', result: `tools: ["read", "tool1", "vscode"]` }, + ].sort(sortByLabel)); + }); + + test('complete tool names inside tools array with existing unquoted entries', async () => { + const content = [ + '---', + 'description: "Test"', + `tools: [read, "tool1", |]`, + '---', + ].join('\n'); + + //uses the first entry to determine quote preference, so the new entry should be unquoted + + const actual = await getCompletions(content, PromptsType.agent); + assert.deepStrictEqual(actual.sort(sortByLabel), [ + { label: 'agent', result: `tools: [read, "tool1", agent]` }, + { label: 'execute', result: `tools: [read, "tool1", execute]` }, + { label: 'tool2', result: `tools: [read, "tool1", tool2]` }, + { label: 'vscode', result: `tools: [read, "tool1", vscode]` }, + ].sort(sortByLabel)); + }); + test('complete tool names inside tools array with existing entries 2', async () => { const content = [ '---', @@ -262,7 +297,6 @@ suite('PromptHeaderAutocompletion', () => { assert.deepStrictEqual(actual.sort(sortByLabel), [ { label: 'agent', result: `tools: ['read', 'agent']` }, { label: 'execute', result: `tools: ['read', 'execute']` }, - { label: 'read', result: `tools: ['read', 'read']` }, { label: 'tool1', result: `tools: ['read', 'tool1']` }, { label: 'tool2', result: `tools: ['read', 'tool2']` }, { label: 'vscode', result: `tools: ['read', 'vscode']` }, @@ -279,7 +313,7 @@ suite('PromptHeaderAutocompletion', () => { const actual = await getCompletions(content, PromptsType.agent); assert.deepStrictEqual(actual.sort(sortByLabel), [ - { label: 'agent1', result: `agents: ['agent1']` }, + { label: 'agent1', result: `agents: [agent1]` }, ].sort(sortByLabel)); }); @@ -329,6 +363,155 @@ suite('PromptHeaderAutocompletion', () => { }); }); + suite('claude agent header completions', () => { + // Claude agents are identified by their URI being under .claude/agents/ + const claudeAgentUri = URI.parse('test:///.claude/agents/security-reviewer.agent.md'); + + test('complete attribute names', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + '|', + '---', + 'You are a senior security engineer.', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(actual.sort(sortByLabel), [ + { label: 'disallowedTools', result: 'disallowedTools: $0' }, + { label: 'hooks', result: 'hooks: $0' }, + { label: 'mcpServers', result: 'mcpServers: $0' }, + { label: 'memory', result: 'memory: ${0:user}' }, + { label: 'model', result: 'model: ${0:sonnet}' }, + { label: 'permissionMode', result: 'permissionMode: ${0:default}' }, + { label: 'skills', result: 'skills: $0' }, + { label: 'tools', result: 'tools: $0' }, + ].sort(sortByLabel)); + }); + + test('complete attribute names excludes already present ones', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + 'tools: Edit', + '|', + '---', + 'You are a senior security engineer.', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent, claudeAgentUri); + // 'tools' should not appear since it is already in the header + const labels = actual.map(a => a.label).sort(); + assert.ok(!labels.includes('tools'), 'tools should not be suggested when already present'); + assert.ok(!labels.includes('name'), 'name should not be suggested when already present'); + assert.ok(!labels.includes('description'), 'description should not be suggested when already present'); + }); + + test('complete model attribute value with claude enum values', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + 'model: |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(actual.sort(sortByLabel), [ + { label: 'haiku', result: 'model: haiku' }, + { label: 'inherit', result: 'model: inherit' }, + { label: 'opus', result: 'model: opus' }, + { label: 'sonnet', result: 'model: sonnet' }, + ].sort(sortByLabel)); + }); + + test('complete tools with comma-separated values', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + 'tools: Edit, |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent, claudeAgentUri); + const labels = actual.map(a => a.label).sort(); + assert.deepStrictEqual(labels, [ + 'AskUserQuestion', 'Bash', 'Glob', 'Grep', + 'LSP', 'MCPSearch', 'NotebookEdit', 'Read', 'Skill', + 'Task', 'WebFetch', 'WebSearch', 'Write' + ].sort()); + }); + + test('complete tools inside array syntax', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + 'tools: [|]', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent, claudeAgentUri); + const labels = actual.map(a => a.label).sort(); + assert.deepStrictEqual(labels, [ + 'AskUserQuestion', 'Bash', 'Edit', 'Glob', 'Grep', + 'LSP', 'MCPSearch', 'NotebookEdit', 'Read', 'Skill', + 'Task', 'WebFetch', 'WebSearch', 'Write' + ].sort()); + // Array items without quotes should use the name directly + assert.deepStrictEqual(actual.find(a => a.label === 'Edit')?.result, `tools: [Edit]`); + }); + + test('complete tools inside array with existing entries', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + `tools: [Edit, |]`, + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(actual.find(a => a.label === 'Read')?.result, `tools: [Edit, Read]`); + assert.deepStrictEqual(actual.find(a => a.label === 'Bash')?.result, `tools: [Edit, Bash]`); + }); + + test('complete disallowedTools with comma-separated values', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + 'disallowedTools: |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent, claudeAgentUri); + const labels = actual.map(a => a.label).sort(); + assert.deepStrictEqual(labels, [ + 'AskUserQuestion', 'Bash', 'Edit', 'Glob', 'Grep', + 'LSP', 'MCPSearch', 'NotebookEdit', 'Read', 'Skill', + 'Task', 'WebFetch', 'WebSearch', 'Write' + ].sort()); + }); + + test('complete disallowedTools inside array syntax', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + 'disallowedTools: [Bash, |]', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(actual.find(a => a.label === 'Write')?.result, `disallowedTools: [Bash, Write]`); + assert.deepStrictEqual(actual.find(a => a.label === 'Edit')?.result, `disallowedTools: [Bash, Edit]`); + }); + }); + suite('prompt header completions', () => { test('complete model attribute name', async () => { const content = [ diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts index ebff561a2c048..476b8011ad05a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts @@ -18,7 +18,7 @@ import { ChatAgentLocation, ChatConfiguration } from '../../../../common/constan import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../../../common/tools/languageModelToolsService.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../common/languageModels.js'; import { PromptHoverProvider } from '../../../../common/promptSyntax/languageProviders/promptHovers.js'; -import { IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; import { MockChatModeService } from '../../../common/mockChatModeService.js'; import { createTextModel } from '../../../../../../../editor/test/common/testTextModel.js'; import { URI } from '../../../../../../../base/common/uri.js'; @@ -55,6 +55,10 @@ suite('PromptHoverProvider', () => { const testModels: ILanguageModelChatMetadata[] = [ { id: 'mae-4', name: 'MAE 4', vendor: 'olama', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata, { id: 'mae-4.1', name: 'MAE 4.1', vendor: 'copilot', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata, + // Claude model equivalents + { id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5', vendor: 'copilot', version: '1.0', family: 'claude', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 200000, maxOutputTokens: 8192, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: {} } satisfies ILanguageModelChatMetadata, + { id: 'claude-opus-4.6', name: 'Claude Opus 4.6', vendor: 'copilot', version: '1.0', family: 'claude', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 200000, maxOutputTokens: 8192, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: {} } satisfies ILanguageModelChatMetadata, + { id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5', vendor: 'copilot', version: '1.0', family: 'claude', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 200000, maxOutputTokens: 8192, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: {} } satisfies ILanguageModelChatMetadata, ]; instaService.stub(ILanguageModelsService, { @@ -74,6 +78,7 @@ suite('PromptHoverProvider', () => { name: 'BeastMode', agentInstructions: { content: 'Beast mode instructions', toolReferences: [] }, source: { storage: PromptsStorage.local }, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }); instaService.stub(IChatModeService, new MockChatModeService({ builtin: [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit], custom: [customChatMode] })); @@ -88,9 +93,11 @@ suite('PromptHoverProvider', () => { hoverProvider = instaService.createInstance(PromptHoverProvider); }); - async function getHover(content: string, line: number, column: number, promptType: PromptsType): Promise { + async function getHover(content: string, line: number, column: number, promptType: PromptsType, options?: { claudeAgent?: boolean }): Promise { const languageId = getLanguageIdForPromptsType(promptType); - const uri = URI.parse('test:///test' + getPromptFileExtension(promptType)); + const ext = getPromptFileExtension(promptType); + const path = options?.claudeAgent ? `/.claude/agents/test${ext}` : `/test${ext}`; + const uri = URI.parse('test://' + path); const model = disposables.add(createTextModel(content, languageId, undefined, uri)); const position = new Position(line, column); const hover = await hoverProvider.provideHover(model, position, CancellationToken.None); @@ -168,7 +175,7 @@ suite('PromptHoverProvider', () => { const expected = [ 'Possible handoff actions when the agent has completed its task.', '', - 'Note: This attribute is not used when target is github-copilot.' + 'Note: This attribute is not used in GitHub Copilot or Claude targets.' ].join('\n'); assert.strictEqual(hover, expected); }); @@ -495,4 +502,281 @@ suite('PromptHoverProvider', () => { assert.strictEqual(hover, undefined); }); }); + + suite('claude agent hovers', () => { + // Helper that creates a hover in a Claude agent file (URI under .claude/agents/) + async function getClaudeHover(content: string, line: number, column: number): Promise { + return getHover(content, line, column, PromptsType.agent, { claudeAgent: true }); + } + + test('hover on name attribute shows Claude description', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 2, 1); + assert.strictEqual(hover, 'Unique identifier using lowercase letters and hyphens (required)'); + }); + + test('hover on description attribute shows Claude description', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 3, 1); + assert.strictEqual(hover, 'When to delegate to this subagent (required)'); + }); + + test('hover on tools attribute shows Claude description', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + 'tools: Edit, Grep, AskUserQuestion, WebFetch', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + assert.strictEqual(hover, 'Array of tools the subagent can use. Inherits all tools if omitted'); + }); + + test('hover on individual Claude tool shows tool description', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code', + `tools: ['Edit', 'Grep', 'WebFetch']`, + '---', + ].join('\n'); + // Hover on 'Edit' tool + const hoverEdit = await getClaudeHover(content, 4, 10); + assert.strictEqual(hoverEdit, 'Make targeted file edits'); + + // Hover on 'Grep' tool + const hoverGrep = await getClaudeHover(content, 4, 17); + assert.strictEqual(hoverGrep, 'Search file contents with regex'); + + // Hover on 'WebFetch' tool + const hoverFetch = await getClaudeHover(content, 4, 27); + assert.strictEqual(hoverFetch, 'Fetch URL content'); + }); + + test('hover on model attribute shows Claude description', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code', + 'model: opus', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + const expected = [ + 'Model to use: sonnet, opus, haiku, or inherit. Defaults to inherit.', + '', + 'Claude model `opus` maps to the following model:', + '', + '- Name: Claude Opus 4.6', + '- Family: claude', + '- Vendor: copilot' + ].join('\n'); + assert.strictEqual(hover, expected); + }); + + test('hover on model attribute with sonnet value', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: sonnet', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + const expected = [ + 'Model to use: sonnet, opus, haiku, or inherit. Defaults to inherit.', + '', + 'Claude model `sonnet` maps to the following model:', + '', + '- Name: Claude Sonnet 4.5', + '- Family: claude', + '- Vendor: copilot' + ].join('\n'); + assert.strictEqual(hover, expected); + }); + + test('hover on model attribute with haiku value', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: haiku', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + const expected = [ + 'Model to use: sonnet, opus, haiku, or inherit. Defaults to inherit.', + '', + 'Claude model `haiku` maps to the following model:', + '', + '- Name: Claude Haiku 4.5', + '- Family: claude', + '- Vendor: copilot' + ].join('\n'); + assert.strictEqual(hover, expected); + }); + + test('hover on model attribute with inherit value', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: inherit', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + const expected = [ + 'Model to use: sonnet, opus, haiku, or inherit. Defaults to inherit.', + '', + 'Inherit model from parent agent or prompt' + ].join('\n'); + assert.strictEqual(hover, expected); + }); + + test('hover on disallowedTools attribute shows Claude description', async () => { + const content = [ + '---', + 'name: read-only-agent', + 'description: Read-only analysis agent', + `disallowedTools: ['Write', 'Edit', 'Bash']`, + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + assert.strictEqual(hover, 'Tools to deny, removed from inherited or specified list'); + }); + + test('hover on individual disallowedTools value shows tool description', async () => { + const content = [ + '---', + 'name: read-only-agent', + 'description: Read-only', + `disallowedTools: ['Bash', 'Write']`, + '---', + ].join('\n'); + // Hover on 'Bash' tool value + const hoverBash = await getClaudeHover(content, 4, 20); + assert.strictEqual(hoverBash, 'Execute shell commands'); + + // Hover on 'Write' tool value + const hoverWrite = await getClaudeHover(content, 4, 28); + assert.strictEqual(hoverWrite, 'Create/overwrite files'); + }); + + test('hover on permissionMode attribute shows Claude description', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'permissionMode: acceptEdits', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + assert.strictEqual(hover, 'Permission mode: default, acceptEdits, dontAsk, bypassPermissions, or plan.'); + }); + + test('hover on memory attribute shows Claude description', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'memory: project', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + assert.strictEqual(hover, 'Persistent memory scope: user, project, or local. Enables cross-session learning.'); + }); + + test('hover on skills attribute shows Claude description', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'skills: ["code-review"]', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + assert.strictEqual(hover, 'Skills to load into the subagent\'s context at startup.'); + }); + + test('hover on hooks attribute shows Claude description', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'hooks: {}', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + assert.strictEqual(hover, 'Lifecycle hooks scoped to this subagent.'); + }); + + test('hover on handoffs attribute in Claude agent shows not-used note', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'handoffs:', + ' - label: Test', + ' agent: Default', + ' prompt: Test', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + // handoffs is not a Claude attribute, so no hover should appear + assert.strictEqual(hover, undefined); + }); + + test('full example: hover on each attribute of a Claude agent', async () => { + // Realistic Claude agent file as user provided + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + `tools: ['Edit', 'Grep', 'AskUserQuestion', 'WebFetch']`, + 'model: opus', + '---', + 'You are a senior security engineer.', + ].join('\n'); + + // Hover on name (line 2) + const nameHover = await getClaudeHover(content, 2, 1); + assert.strictEqual(nameHover, 'Unique identifier using lowercase letters and hyphens (required)'); + + // Hover on description (line 3) + const descHover = await getClaudeHover(content, 3, 1); + assert.strictEqual(descHover, 'When to delegate to this subagent (required)'); + + // Hover on tools attribute key (line 4, column 1) + const toolsHover = await getClaudeHover(content, 4, 1); + assert.strictEqual(toolsHover, 'Array of tools the subagent can use. Inherits all tools if omitted'); + + // Hover on 'AskUserQuestion' tool value (line 4) + const askHover = await getClaudeHover(content, 4, 28); + assert.strictEqual(askHover, 'Ask multiple-choice questions'); + + // Hover on model value 'opus' (line 5) + const modelHover = await getClaudeHover(content, 5, 1); + const expectedModelHover = [ + 'Model to use: sonnet, opus, haiku, or inherit. Defaults to inherit.', + '', + 'Claude model `opus` maps to the following model:', + '', + '- Name: Claude Opus 4.6', + '- Family: claude', + '- Vendor: copilot' + ].join('\n'); + assert.strictEqual(modelHover, expectedModelHover); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index fafa0b4fdb7e2..4a17af57e5094 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -25,7 +25,7 @@ import { getPromptFileExtension } from '../../../../common/promptSyntax/config/p import { PromptValidator } from '../../../../common/promptSyntax/languageProviders/promptValidator.js'; import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; -import { ICustomAgent, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ICustomAgent, IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; import { MockChatModeService } from '../../../common/mockChatModeService.js'; import { MockPromptsService } from '../../../common/promptSyntax/service/mockPromptsService.js'; @@ -135,6 +135,7 @@ suite('PromptValidator', () => { name: 'BeastMode', agentInstructions: { content: 'Beast mode instructions', toolReferences: [] }, source: { storage: PromptsStorage.local }, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }); instaService.stub(IChatModeService, new MockChatModeService({ builtin: [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit], custom: [customChatMode] })); @@ -154,6 +155,7 @@ suite('PromptValidator', () => { tools: ['tool1', 'tool2'], agentInstructions: { content: 'Custom mode body', toolReferences: [] }, source: { storage: PromptsStorage.local }, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; promptsService.setCustomModes([customMode]); @@ -206,7 +208,7 @@ suite('PromptValidator', () => { ); }); - test('tools must be array', async () => { + test('tools must be array or string', async () => { const content = [ '---', 'description: "Test"', @@ -214,8 +216,7 @@ suite('PromptValidator', () => { '---', ].join('\n'); const markers = await validate(content, PromptsType.agent); - assert.strictEqual(markers.length, 1); - assert.deepStrictEqual(markers.map(m => m.message), [`The 'tools' attribute must be an array.`]); + assert.strictEqual(markers.length, 0); }); test('model as string array - valid', async () => { @@ -1853,4 +1854,271 @@ suite('PromptValidator', () => { }); + suite('claude agents', () => { + + // Helper URI for Claude agents — file must be under .claude/agents/ for target detection + const claudeAgentUri = URI.parse('myFs://test/.claude/agents/test.agent.md'); + + test('valid Claude agent with all common attributes', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + `tools: ['Edit', 'Grep', 'AskUserQuestion', 'WebFetch']`, + 'model: opus', + 'permissionMode: delegate', + '---', + 'You are a senior security engineer.', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, []); + }); + + test('valid Claude agent with minimal attributes', async () => { + const content = [ + '---', + 'name: helper', + 'description: A simple helper agent', + '---', + 'You help with tasks.', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, []); + }); + + test('Claude agent with valid model values', async () => { + // Each known Claude model should be valid + for (const modelName of ['sonnet', 'opus', 'haiku', 'inherit']) { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + `model: ${modelName}`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, [], `Model '${modelName}' should be valid`); + } + }); + + test('Claude agent with unknown model value', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: gpt-4', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, `Unknown value 'gpt-4', valid: sonnet, opus, haiku, inherit.`); + }); + + test('Claude agent with non-string model value', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: 123', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `The 'model' attribute must be a string.`); + }); + + test('Claude agent with valid permissionMode values', async () => { + for (const mode of ['default', 'acceptEdits', 'plan', 'delegate', 'dontAsk', 'bypassPermissions']) { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + `permissionMode: ${mode}`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, [], `permissionMode '${mode}' should be valid`); + } + }); + + test('Claude agent with unknown permissionMode value', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: sonnet', + 'permissionMode: allowAll', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, `Unknown value 'allowAll', valid: default, acceptEdits, plan, delegate, dontAsk, bypassPermissions.`); + }); + + test('Claude agent with non-string permissionMode value', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: sonnet', + 'permissionMode: true', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `The 'permissionMode' attribute must be a string.`); + }); + + test('Claude agent with valid memory values', async () => { + for (const mem of ['user', 'project', 'local']) { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + `memory: ${mem}`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, [], `memory '${mem}' should be valid`); + } + }); + + test('Claude agent with unknown memory value', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: sonnet', + 'permissionMode: default', + 'memory: global', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, `Unknown value 'global', valid: user, project, local.`); + }); + + test('Claude agent with empty name shows error', async () => { + const content = [ + '---', + 'name: ""', + 'description: Test', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `The 'name' attribute must not be empty.`); + }); + + test('Claude agent with empty description shows error', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: ""', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `The 'description' attribute should not be empty.`); + }); + + test('Claude agent with unknown attributes does not warn', async () => { + // Claude target ignores unknown attributes since we don't have a full list + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'customAttribute: someValue', + 'anotherCustom: 123', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, [], 'Unknown attributes should be silently ignored for Claude agents'); + }); + + test('Claude agent tools are not validated against VS Code tool registry', async () => { + // Claude tool names (Edit, Grep, etc.) don't exist in VS Code's tool registry + // but should not produce warnings for Claude target + const content = [ + '---', + 'name: test-agent', + 'description: Test', + `tools: ['Edit', 'Grep', 'UnknownClaudeTool', 'WebFetch']`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, [], 'Claude tools should not be validated against VS Code registry'); + }); + + test('Claude agent with comma-separated tools string', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code', + 'tools: Edit, Grep, AskUserQuestion, WebFetch', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, [], 'Comma-separated tools string should be valid for Claude'); + }); + + test('Claude agent does not validate handoffs or agents attributes', async () => { + // handoffs and agents are VS Code-specific; they shouldn't be validated for Claude + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: opus', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, []); + }); + + test('Claude agent full realistic example', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + `tools: ['Edit', 'Grep', 'AskUserQuestion', 'WebFetch']`, + 'model: opus', + 'permissionMode: delegate', + 'memory: project', + '---', + 'You are a senior security engineer.', + 'Review the code for common vulnerabilities.', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, []); + }); + + test('Claude agent with multiple validation errors', async () => { + const content = [ + '---', + 'name: ""', + 'description: ""', + 'model: unknown-model', + 'permissionMode: invalid-mode', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'name' attribute must not be empty.` }, + { severity: MarkerSeverity.Error, message: `The 'description' attribute should not be empty.` }, + { severity: MarkerSeverity.Warning, message: `Unknown value 'unknown-model', valid: sonnet, opus, haiku, inherit.` }, + { severity: MarkerSeverity.Warning, message: `Unknown value 'invalid-mode', valid: default, acceptEdits, plan, delegate, dontAsk, bypassPermissions.` }, + ] + ); + }); + }); + }); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index 30f950f8ca6d5..4b6f909e845d5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -744,7 +744,7 @@ suite('LanguageModelToolsService', () => { // Test with enabled tool { const fullReferenceNames = ['tool1RefName']; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 1, 'Expected 1 tool to be enabled'); assert.strictEqual(result1.get(tool1), true, 'tool1 should be enabled'); @@ -756,7 +756,7 @@ suite('LanguageModelToolsService', () => { // Test with multiple enabled tools { const fullReferenceNames = ['my.extension/extTool1RefName', 'mcpToolSetRefName/*', 'internalToolSetRefName/internalToolSetTool1RefName']; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 4, 'Expected 4 tools to be enabled'); assert.strictEqual(result1.get(extTool1), true, 'extTool1 should be enabled'); @@ -769,7 +769,7 @@ suite('LanguageModelToolsService', () => { } // Test with all enabled tools, redundant names { - const result1 = service.toToolAndToolSetEnablementMap(allFullReferenceNames, undefined, undefined); + const result1 = service.toToolAndToolSetEnablementMap(allFullReferenceNames, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 12, 'Expected 12 tools to be enabled'); // +4 including the vscode, execute, read, agent toolsets @@ -780,7 +780,7 @@ suite('LanguageModelToolsService', () => { // Test with no enabled tools { const fullReferenceNames: string[] = []; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 0, 'Expected 0 tools to be enabled'); @@ -790,7 +790,7 @@ suite('LanguageModelToolsService', () => { // Test with unknown tool { const fullReferenceNames: string[] = ['unknownToolRefName']; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 0, 'Expected 0 tools to be enabled'); @@ -800,7 +800,7 @@ suite('LanguageModelToolsService', () => { // Test with legacy tool names { const fullReferenceNames: string[] = ['extTool1RefName', 'mcpToolSetRefName', 'internalToolSetTool1RefName']; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 4, 'Expected 4 tools to be enabled'); assert.strictEqual(result1.get(extTool1), true, 'extTool1 should be enabled'); @@ -815,7 +815,7 @@ suite('LanguageModelToolsService', () => { // Test with tool in user tool set { const fullReferenceNames = ['Tool2 Display Name']; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 2, 'Expected 1 tool and user tool set to be enabled'); assert.strictEqual(result1.get(tool2), true, 'tool2 should be enabled'); @@ -842,7 +842,7 @@ suite('LanguageModelToolsService', () => { // Test enabling the tool set const enabledNames = [toolData1].map(t => service.getFullReferenceName(t)); - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined); assert.strictEqual(result.get(toolData1), true, 'individual tool should be enabled'); @@ -902,7 +902,7 @@ suite('LanguageModelToolsService', () => { // Test enabling the tool set const enabledNames = [toolSet, toolData1].map(t => service.getFullReferenceName(t)); - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined); assert.strictEqual(result.get(toolData1), true, 'individual tool should be enabled'); assert.strictEqual(result.get(toolData2), false); @@ -937,7 +937,7 @@ suite('LanguageModelToolsService', () => { // Test with non-existent tool names const enabledNames = [toolData, unregisteredToolData].map(t => service.getFullReferenceName(t)); - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined); assert.strictEqual(result.get(toolData), true, 'existing tool should be enabled'); // Non-existent tools should not appear in the result map @@ -986,7 +986,7 @@ suite('LanguageModelToolsService', () => { // Test 1: Using legacy tool reference name should enable the tool { - const result = service.toToolAndToolSetEnablementMap(['oldToolName'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolName'], undefined); assert.strictEqual(result.get(toolWithLegacy), true, 'tool should be enabled via legacy name'); const fullReferenceNames = service.toFullReferenceNames(result); @@ -995,7 +995,7 @@ suite('LanguageModelToolsService', () => { // Test 2: Using another legacy tool reference name should also work { - const result = service.toToolAndToolSetEnablementMap(['deprecatedToolName'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['deprecatedToolName'], undefined); assert.strictEqual(result.get(toolWithLegacy), true, 'tool should be enabled via another legacy name'); const fullReferenceNames = service.toFullReferenceNames(result); @@ -1004,7 +1004,7 @@ suite('LanguageModelToolsService', () => { // Test 3: Using legacy toolset name should enable the entire toolset { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined); assert.strictEqual(result.get(toolSetWithLegacy), true, 'toolset should be enabled via legacy name'); assert.strictEqual(result.get(toolInSet), true, 'tool in set should be enabled when set is enabled via legacy name'); @@ -1014,7 +1014,7 @@ suite('LanguageModelToolsService', () => { // Test 4: Using deprecated toolset name should also work { - const result = service.toToolAndToolSetEnablementMap(['deprecatedToolSet'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['deprecatedToolSet'], undefined); assert.strictEqual(result.get(toolSetWithLegacy), true, 'toolset should be enabled via another legacy name'); assert.strictEqual(result.get(toolInSet), true, 'tool in set should be enabled when set is enabled via legacy name'); @@ -1024,7 +1024,7 @@ suite('LanguageModelToolsService', () => { // Test 5: Mix of current and legacy names { - const result = service.toToolAndToolSetEnablementMap(['newToolRef', 'oldToolSet'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['newToolRef', 'oldToolSet'], undefined); assert.strictEqual(result.get(toolWithLegacy), true, 'tool should be enabled via current name'); assert.strictEqual(result.get(toolSetWithLegacy), true, 'toolset should be enabled via legacy name'); assert.strictEqual(result.get(toolInSet), true, 'tool in set should be enabled'); @@ -1035,7 +1035,7 @@ suite('LanguageModelToolsService', () => { // Test 6: Using legacy names and current names together (redundant but should work) { - const result = service.toToolAndToolSetEnablementMap(['newToolRef', 'oldToolName', 'deprecatedToolName'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['newToolRef', 'oldToolName', 'deprecatedToolName'], undefined); assert.strictEqual(result.get(toolWithLegacy), true, 'tool should be enabled (redundant legacy names should not cause issues)'); const fullReferenceNames = service.toFullReferenceNames(result); @@ -1061,7 +1061,7 @@ suite('LanguageModelToolsService', () => { // Test 1: Using the full legacy name should enable the tool { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet/oldToolName'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet/oldToolName'], undefined); assert.strictEqual(result.get(toolWithOrphanedToolSet), true, 'tool should be enabled via full legacy name'); const fullReferenceNames = service.toFullReferenceNames(result); @@ -1070,7 +1070,7 @@ suite('LanguageModelToolsService', () => { // Test 2: Using just the orphaned toolset name should also enable the tool { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined); assert.strictEqual(result.get(toolWithOrphanedToolSet), true, 'tool should be enabled via orphaned toolset name'); const fullReferenceNames = service.toFullReferenceNames(result); @@ -1090,7 +1090,7 @@ suite('LanguageModelToolsService', () => { store.add(service.registerToolData(anotherToolFromOrphanedSet)); { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined); assert.strictEqual(result.get(toolWithOrphanedToolSet), true, 'first tool should be enabled via orphaned toolset name'); assert.strictEqual(result.get(anotherToolFromOrphanedSet), true, 'second tool should also be enabled via orphaned toolset name'); @@ -1111,7 +1111,7 @@ suite('LanguageModelToolsService', () => { store.add(service.registerToolData(unrelatedTool)); { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined); assert.strictEqual(result.get(toolWithOrphanedToolSet), true, 'tool from oldToolSet should be enabled'); assert.strictEqual(result.get(anotherToolFromOrphanedSet), true, 'another tool from oldToolSet should be enabled'); assert.strictEqual(result.get(unrelatedTool), false, 'tool from different toolset should NOT be enabled'); @@ -1139,7 +1139,7 @@ suite('LanguageModelToolsService', () => { store.add(newToolSetWithSameName.addTool(toolInRecreatedSet)); { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined); // Now 'oldToolSet' should enable BOTH the recreated toolset AND the tools with legacy names pointing to oldToolSet assert.strictEqual(result.get(newToolSetWithSameName), true, 'recreated toolset should be enabled'); assert.strictEqual(result.get(toolInRecreatedSet), true, 'tool in recreated set should be enabled'); @@ -1229,7 +1229,7 @@ suite('LanguageModelToolsService', () => { { const toolNames = ['custom-agent', 'shell']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); assert.strictEqual(result.get(service.executeToolSet), true, 'execute should be enabled'); assert.strictEqual(result.get(service.agentToolSet), true, 'agent should be enabled'); @@ -1244,7 +1244,7 @@ suite('LanguageModelToolsService', () => { } { const toolNames = ['github/*', 'playwright/*']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); assert.strictEqual(result.get(githubMcpToolSet), true, 'githubMcpToolSet should be enabled'); assert.strictEqual(result.get(playwrightMcpToolSet), true, 'playwrightMcpToolSet should be enabled'); @@ -1260,7 +1260,7 @@ suite('LanguageModelToolsService', () => { { // the speced names should work and not be altered const toolNames = ['github/create_branch', 'playwright/browser_click']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); assert.strictEqual(result.get(githubMcpTool1), true, 'githubMcpTool1 should be enabled'); assert.strictEqual(result.get(playwrightMcpTool1), true, 'playwrightMcpTool1 should be enabled'); @@ -1276,7 +1276,7 @@ suite('LanguageModelToolsService', () => { { // using the old MCP full names should also work const toolNames = ['github/github-mcp-server/*', 'microsoft/playwright-mcp/*']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); assert.strictEqual(result.get(githubMcpToolSet), true, 'githubMcpToolSet should be enabled'); assert.strictEqual(result.get(playwrightMcpToolSet), true, 'playwrightMcpToolSet should be enabled'); @@ -1291,7 +1291,7 @@ suite('LanguageModelToolsService', () => { { // using the old MCP full names should also work const toolNames = ['github/github-mcp-server/create_branch', 'microsoft/playwright-mcp/browser_click']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); assert.strictEqual(result.get(githubMcpTool1), true, 'githubMcpTool1 should be enabled'); assert.strictEqual(result.get(playwrightMcpTool1), true, 'playwrightMcpTool1 should be enabled'); @@ -1307,7 +1307,7 @@ suite('LanguageModelToolsService', () => { { // using the latest MCP full names should also work const toolNames = ['io.github.github/github-mcp-server/*', 'com.microsoft/playwright-mcp/*']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); assert.strictEqual(result.get(githubMcpToolSet), true, 'githubMcpToolSet should be enabled'); assert.strictEqual(result.get(playwrightMcpToolSet), true, 'playwrightMcpToolSet should be enabled'); @@ -1323,7 +1323,7 @@ suite('LanguageModelToolsService', () => { { // using the latest MCP full names should also work const toolNames = ['io.github.github/github-mcp-server/create_branch', 'com.microsoft/playwright-mcp/browser_click']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); assert.strictEqual(result.get(githubMcpTool1), true, 'githubMcpTool1 should be enabled'); assert.strictEqual(result.get(playwrightMcpTool1), true, 'playwrightMcpTool1 should be enabled'); @@ -1339,7 +1339,7 @@ suite('LanguageModelToolsService', () => { { // using the old MCP full names should also work const toolNames = ['github-mcp-server/create_branch']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); assert.strictEqual(result.get(githubMcpTool1), true, 'githubMcpTool1 should be enabled'); const fullReferenceNames = service.toFullReferenceNames(result).sort(); @@ -2155,7 +2155,7 @@ suite('LanguageModelToolsService', () => { // Enable the MCP toolset { const enabledNames = [mcpToolSet].map(t => service.getFullReferenceName(t)); - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined); assert.strictEqual(result.get(mcpToolSet), true, 'MCP toolset should be enabled'); // Ensure the toolset is in the map assert.strictEqual(result.get(mcpTool), true, 'MCP tool should be enabled when its toolset is enabled'); // Ensure the tool is in the map @@ -2166,7 +2166,7 @@ suite('LanguageModelToolsService', () => { // Enable a tool from the MCP toolset { const enabledNames = [mcpTool].map(t => service.getFullReferenceName(t, mcpToolSet)); - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined); assert.strictEqual(result.get(mcpToolSet), false, 'MCP toolset should be disabled'); // Ensure the toolset is in the map assert.strictEqual(result.get(mcpTool), true, 'MCP tool should be enabled'); // Ensure the tool is in the map @@ -2869,7 +2869,7 @@ suite('LanguageModelToolsService', () => { // Provide model metadata for gpt-4 family const modelMetadata = { id: 'gpt-4-turbo', vendor: 'openai', family: 'gpt-4', version: '1.0' } as ILanguageModelChatMetadata; const enabledNames = ['gpt4ToolRef', 'anyModelToolRef', 'claudeToolRef']; - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, modelMetadata); + const result = service.toToolAndToolSetEnablementMap(enabledNames, modelMetadata); // gpt4Tool should be enabled (model matches) assert.strictEqual(result.get(gpt4Tool), true, 'gpt4Tool should be enabled'); diff --git a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts index 5f1d294077660..75f22e0c69f2b 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts @@ -19,7 +19,7 @@ import { TestStorageService } from '../../../../test/common/workbenchTestService import { IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatMode, ChatModeService } from '../../common/chatModes.js'; import { ChatModeKind } from '../../common/constants.js'; -import { IAgentSource, ICustomAgent, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { IAgentSource, ICustomAgent, IPromptsService, PromptsStorage, Target } from '../../common/promptSyntax/service/promptsService.js'; import { MockPromptsService } from './promptSyntax/service/mockPromptsService.js'; class TestChatAgentService implements Partial { @@ -119,6 +119,7 @@ suite('ChatModeService', () => { tools: ['tool1', 'tool2'], agentInstructions: { content: 'Custom mode body', toolReferences: [] }, source: workspaceSource, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; @@ -156,6 +157,7 @@ suite('ChatModeService', () => { tools: [], agentInstructions: { content: 'Custom mode body', toolReferences: [] }, source: workspaceSource, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; @@ -175,6 +177,7 @@ suite('ChatModeService', () => { tools: [], agentInstructions: { content: 'Findable mode body', toolReferences: [] }, source: workspaceSource, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; @@ -200,6 +203,7 @@ suite('ChatModeService', () => { agentInstructions: { content: 'Initial body', toolReferences: [] }, model: ['gpt-4'], source: workspaceSource, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; @@ -244,6 +248,7 @@ suite('ChatModeService', () => { tools: [], agentInstructions: { content: 'Mode 1 body', toolReferences: [] }, source: workspaceSource, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; @@ -254,6 +259,7 @@ suite('ChatModeService', () => { tools: [], agentInstructions: { content: 'Mode 2 body', toolReferences: [] }, source: workspaceSource, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index c08c536df204b..4f4fc6b908493 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -13,6 +13,7 @@ import { IChatAgentAttachmentCapabilities } from '../../common/participants/chat import { IChatModel } from '../../common/model/chatModel.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemProvider, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService } from '../../common/chatSessionsService.js'; +import { Target } from '../../common/promptSyntax/service/promptsService.js'; export class MockChatSessionsService implements IChatSessionsService { _serviceBrand: undefined; @@ -187,8 +188,8 @@ export class MockChatSessionsService implements IChatSessionsService { return this.contributions.find(c => c.type === chatSessionType)?.capabilities; } - getCustomAgentTargetForSessionType(chatSessionType: string): string | undefined { - return this.contributions.find(c => c.type === chatSessionType)?.customAgentTarget; + getCustomAgentTargetForSessionType(chatSessionType: string): Target { + return this.contributions.find(c => c.type === chatSessionType)?.customAgentTarget ?? Target.Undefined; } getContentProviderSchemes(): string[] { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts similarity index 77% rename from src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts rename to src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts index 0c70640aadb0e..156e28227d776 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts @@ -8,9 +8,9 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; import { URI } from '../../../../../../../base/common/uri.js'; -import { PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; +import { IStringValue, parseCommaSeparatedList, PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; -suite('NewPromptsParser', () => { +suite('PromptFileParser', () => { ensureNoDisposablesAreLeakedInTestSuite(); test('agent', async () => { @@ -239,75 +239,6 @@ suite('NewPromptsParser', () => { assert.deepEqual(result.header.tools, ['search', 'terminal']); }); - test('prompt file tools as map', async () => { - const uri = URI.parse('file:///test/prompt2.md'); - const content = [ - /* 01 */'---', - /* 02 */'tools:', - /* 03 */' built-in: true', - /* 04 */' mcp:', - /* 05 */' vscode-playright-mcp:', - /* 06 */' browser-click: true', - /* 07 */' extensions:', - /* 08 */' github.vscode-pull-request-github:', - /* 09 */' openPullRequest: true', - /* 10 */' copilotCodingAgent: false', - /* 11 */'---', - ].join('\n'); - const result = new PromptFileParser().parse(uri, content); - assert.deepEqual(result.uri, uri); - assert.ok(result.header); - assert.ok(!result.body); - assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 11, endColumn: 1 }); - assert.deepEqual(result.header.attributes, [ - { - key: 'tools', range: new Range(2, 1, 10, 32), value: { - type: 'object', - properties: [ - { - 'key': { type: 'string', value: 'built-in', range: new Range(3, 3, 3, 11) }, - 'value': { type: 'boolean', value: true, range: new Range(3, 13, 3, 17) } - }, - { - 'key': { type: 'string', value: 'mcp', range: new Range(4, 3, 4, 6) }, - 'value': { - type: 'object', range: new Range(5, 5, 6, 26), properties: [ - { - 'key': { type: 'string', value: 'vscode-playright-mcp', range: new Range(5, 5, 5, 25) }, 'value': { - type: 'object', range: new Range(6, 7, 6, 26), properties: [ - { 'key': { type: 'string', value: 'browser-click', range: new Range(6, 7, 6, 20) }, 'value': { type: 'boolean', value: true, range: new Range(6, 22, 6, 26) } } - ] - } - } - ] - } - }, - { - 'key': { type: 'string', value: 'extensions', range: new Range(7, 3, 7, 13) }, - 'value': { - type: 'object', range: new Range(8, 5, 10, 32), properties: [ - { - 'key': { type: 'string', value: 'github.vscode-pull-request-github', range: new Range(8, 5, 8, 38) }, 'value': { - type: 'object', range: new Range(9, 7, 10, 32), properties: [ - { 'key': { type: 'string', value: 'openPullRequest', range: new Range(9, 7, 9, 22) }, 'value': { type: 'boolean', value: true, range: new Range(9, 24, 9, 28) } }, - { 'key': { type: 'string', value: 'copilotCodingAgent', range: new Range(10, 7, 10, 25) }, 'value': { type: 'boolean', value: false, range: new Range(10, 27, 10, 32) } } - ] - } - } - ] - } - }, - ], - range: new Range(3, 3, 10, 32) - }, - } - ]); - assert.deepEqual(result.header.description, undefined); - assert.deepEqual(result.header.agent, undefined); - assert.deepEqual(result.header.model, undefined); - assert.ok(result.header.tools); - assert.deepEqual(result.header.tools, ['built-in', 'browser-click', 'openPullRequest', 'copilotCodingAgent']); - }); test('agent with agents', async () => { const uri = URI.parse('file:///test/test.agent.md'); @@ -372,4 +303,108 @@ suite('NewPromptsParser', () => { assert.deepEqual(result.header.description, 'Agent without restrictions'); assert.deepEqual(result.header.agents, undefined); }); + + suite('parseCommaSeparatedList', () => { + + function assertCommaSeparatedList(input: string, expected: IStringValue[]): void { + const actual = parseCommaSeparatedList({ type: 'string', value: input, range: new Range(1, 1, 1, input.length + 1) }); + assert.deepStrictEqual(actual.items, expected); + } + + test('simple unquoted values', () => { + assertCommaSeparatedList('a, b, c', [ + { type: 'string', value: 'a', range: new Range(1, 1, 1, 2) }, + { type: 'string', value: 'b', range: new Range(1, 4, 1, 5) }, + { type: 'string', value: 'c', range: new Range(1, 7, 1, 8) } + ]); + }); + + test('unquoted values without spaces', () => { + assertCommaSeparatedList('foo,bar,baz', [ + { type: 'string', value: 'foo', range: new Range(1, 1, 1, 4) }, + { type: 'string', value: 'bar', range: new Range(1, 5, 1, 8) }, + { type: 'string', value: 'baz', range: new Range(1, 9, 1, 12) } + ]); + }); + + test('double quoted values', () => { + assertCommaSeparatedList('"hello", "world"', [ + { type: 'string', value: 'hello', range: new Range(1, 1, 1, 8) }, + { type: 'string', value: 'world', range: new Range(1, 10, 1, 17) } + ]); + }); + + test('single quoted values', () => { + assertCommaSeparatedList(`'one', 'two'`, [ + { type: 'string', value: 'one', range: new Range(1, 1, 1, 6) }, + { type: 'string', value: 'two', range: new Range(1, 8, 1, 13) } + ]); + }); + + test('mixed quoted and unquoted values', () => { + assertCommaSeparatedList('unquoted, "double", \'single\'', [ + { type: 'string', value: 'unquoted', range: new Range(1, 1, 1, 9) }, + { type: 'string', value: 'double', range: new Range(1, 11, 1, 19) }, + { type: 'string', value: 'single', range: new Range(1, 21, 1, 29) } + ]); + }); + + test('quoted values with commas inside', () => { + assertCommaSeparatedList('"a,b", "c,d"', [ + { type: 'string', value: 'a,b', range: new Range(1, 1, 1, 6) }, + { type: 'string', value: 'c,d', range: new Range(1, 8, 1, 13) } + ]); + }); + + test('empty string', () => { + assertCommaSeparatedList('', []); + }); + + test('single value', () => { + assertCommaSeparatedList('single', [ + { type: 'string', value: 'single', range: new Range(1, 1, 1, 7) } + ]); + }); + + test('values with extra whitespace', () => { + assertCommaSeparatedList(' a , b , c ', [ + { type: 'string', value: 'a', range: new Range(1, 3, 1, 4) }, + { type: 'string', value: 'b', range: new Range(1, 9, 1, 10) }, + { type: 'string', value: 'c', range: new Range(1, 15, 1, 16) } + ]); + }); + + test('quoted value with spaces', () => { + assertCommaSeparatedList('"hello world", "foo bar"', [ + { type: 'string', value: 'hello world', range: new Range(1, 1, 1, 14) }, + { type: 'string', value: 'foo bar', range: new Range(1, 16, 1, 25) } + ]); + }); + + test('with position offset', () => { + // Simulate parsing a list that starts at line 5, character 10 + const result = parseCommaSeparatedList({ type: 'string', value: 'a, b, c', range: new Range(6, 11, 6, 18) }); + assert.deepStrictEqual(result.items, [ + { type: 'string', value: 'a', range: new Range(6, 11, 6, 12) }, + { type: 'string', value: 'b', range: new Range(6, 14, 6, 15) }, + { type: 'string', value: 'c', range: new Range(6, 17, 6, 18) } + ]); + }); + + test('entire input wrapped in double quotes', () => { + // When the entire input is wrapped in quotes, it should be treated as a single quoted value + assertCommaSeparatedList('"a, b, c"', [ + { type: 'string', value: 'a, b, c', range: new Range(1, 1, 1, 10) } + ]); + }); + + test('entire input wrapped in single quotes', () => { + // When the entire input is wrapped in single quotes, it should be treated as a single quoted value + assertCommaSeparatedList(`'a, b, c'`, [ + { type: 'string', value: 'a, b, c', range: new Range(1, 1, 1, 10) } + ]); + }); + + }); + }); 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 89c383218f083..9980d5b4f22ea 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 @@ -38,9 +38,9 @@ import { TestContextService, TestUserDataProfileService } from '../../../../../. import { ChatRequestVariableSet, isPromptFileVariableEntry, toFileVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; import { ComputeAutomaticInstructions, newInstructionsCollectionEvent } from '../../../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js'; -import { INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../../common/promptSyntax/config/promptFileLocations.js'; +import { CLAUDE_CONFIG_FOLDER, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; -import { ExtensionAgentSourceType, ICustomAgent, IPromptFileContext, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ExtensionAgentSourceType, ICustomAgent, IPromptFileContext, IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../../../../common/promptSyntax/service/promptsServiceImpl.js'; import { mockFiles } from '../testUtils/mockFilesystem.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../../platform/storage/common/storage.js'; @@ -767,7 +767,7 @@ suite('PromptsService', () => { model: undefined, argumentHint: undefined, tools: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), @@ -823,7 +823,7 @@ suite('PromptsService', () => { handOffs: undefined, model: undefined, argumentHint: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), @@ -841,6 +841,7 @@ suite('PromptsService', () => { }, uri: URI.joinPath(rootFolderUri, '.github/agents/agent2.agent.md'), source: { storage: PromptsStorage.local }, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } } ]; @@ -897,7 +898,7 @@ suite('PromptsService', () => { }, handOffs: undefined, model: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), @@ -915,7 +916,7 @@ suite('PromptsService', () => { handOffs: undefined, model: undefined, tools: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent2.agent.md'), @@ -976,7 +977,7 @@ suite('PromptsService', () => { { name: 'github-agent', description: 'GitHub Copilot specialized agent.', - target: 'github-copilot', + target: Target.GitHubCopilot, tools: ['github-api', 'code-search'], agentInstructions: { content: 'I am optimized for GitHub Copilot workflows.', @@ -994,7 +995,7 @@ suite('PromptsService', () => { { name: 'vscode-agent', description: 'VS Code specialized agent.', - target: 'vscode', + target: Target.VSCode, model: ['gpt-4'], agentInstructions: { content: 'I am specialized for VS Code editor tasks.', @@ -1021,7 +1022,7 @@ suite('PromptsService', () => { model: undefined, argumentHint: undefined, tools: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/generic-agent.agent.md'), @@ -1036,6 +1037,122 @@ suite('PromptsService', () => { ); }); + test('claude agent maps tools and model to vscode equivalents', async () => { + const rootFolderName = 'claude-agent-mapping'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + // Claude agent with tools and model that should be mapped + path: `${rootFolder}/.claude/agents/claude-agent.md`, + contents: [ + '---', + 'description: \'Claude agent with tools and model.\'', + 'tools: [ Read, Edit, Bash ]', + 'model: opus', + '---', + 'I am a Claude agent.', + ] + }, + { + // Claude agent with more tools, some with empty equivalents + path: `${rootFolder}/.claude/agents/claude-agent2.md`, + contents: [ + '---', + 'description: \'Claude agent with various tools.\'', + 'tools: [ Glob, Grep, Write, Task, Skill ]', + 'model: sonnet', + '---', + 'I am another Claude agent.', + ] + }, + { + // Non-Claude agent should NOT have tools/model mapped + path: `${rootFolder}/.github/agents/copilot-agent.agent.md`, + contents: [ + '---', + 'description: \'Copilot agent with same tool names.\'', + 'target: \'github-copilot\'', + 'tools: [ Read, Edit ]', + 'model: gpt-4', + '---', + 'I am a Copilot agent.', + ] + }, + ]); + + const result = (await service.getCustomAgents(CancellationToken.None)).map(agent => ({ ...agent, uri: URI.from(agent.uri) })); + const expected: ICustomAgent[] = [ + { + name: 'copilot-agent', + description: 'Copilot agent with same tool names.', + target: Target.GitHubCopilot, + // Non-Claude agent: tools and model stay as-is + tools: ['Read', 'Edit'], + model: ['gpt-4'], + agentInstructions: { + content: 'I am a Copilot agent.', + toolReferences: [], + metadata: undefined + }, + handOffs: undefined, + argumentHint: undefined, + visibility: { userInvokable: true, agentInvokable: true }, + agents: undefined, + uri: URI.joinPath(rootFolderUri, '.github/agents/copilot-agent.agent.md'), + source: { storage: PromptsStorage.local } + }, + { + name: 'claude-agent', + description: 'Claude agent with tools and model.', + target: Target.Claude, + // Claude tools mapped to vscode equivalents + tools: ['read/readFile', 'read/getNotebookSummary', 'edit/editNotebook', 'edit/editFiles', 'execute'], + // Claude model mapped to vscode equivalent + model: ['Claude Opus 4.6 (copilot)'], + agentInstructions: { + content: 'I am a Claude agent.', + toolReferences: [], + metadata: undefined + }, + handOffs: undefined, + argumentHint: undefined, + visibility: { userInvokable: true, agentInvokable: true }, + agents: undefined, + uri: URI.joinPath(rootFolderUri, '.claude/agents/claude-agent.md'), + source: { storage: PromptsStorage.local } + }, + { + name: 'claude-agent2', + description: 'Claude agent with various tools.', + target: Target.Claude, + // Tools mapped: Glob->search/fileSearch, Grep->search/textSearch, Write->edit/create*, Task->agent, Skill->[] (empty) + tools: ['search/fileSearch', 'search/textSearch', 'edit/createDirectory', 'edit/createFile', 'edit/createJupyterNotebook', 'agent'], + model: ['Claude Sonnet 4.5 (copilot)'], + agentInstructions: { + content: 'I am another Claude agent.', + toolReferences: [], + metadata: undefined + }, + handOffs: undefined, + argumentHint: undefined, + visibility: { userInvokable: true, agentInvokable: true }, + agents: undefined, + uri: URI.joinPath(rootFolderUri, '.claude/agents/claude-agent2.md'), + source: { storage: PromptsStorage.local } + }, + ]; + + assert.deepEqual( + result, + expected, + 'Claude tools and models must be mapped to VS Code equivalents; non-Claude agents must remain unchanged.', + ); + }); + test('agents with .md extension should be recognized, except README.md', async () => { const rootFolderName = 'custom-agents-md-extension'; const rootFolder = `/${rootFolderName}`; @@ -1076,7 +1193,7 @@ suite('PromptsService', () => { handOffs: undefined, model: undefined, argumentHint: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/demonstrate.md'), @@ -1147,7 +1264,7 @@ suite('PromptsService', () => { handOffs: undefined, model: undefined, argumentHint: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, uri: URI.joinPath(rootFolderUri, '.github/agents/restricted-agent.agent.md'), source: { storage: PromptsStorage.local } @@ -1165,7 +1282,7 @@ suite('PromptsService', () => { model: undefined, argumentHint: undefined, tools: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, uri: URI.joinPath(rootFolderUri, '.github/agents/no-access-agent.agent.md'), source: { storage: PromptsStorage.local } @@ -1183,7 +1300,7 @@ suite('PromptsService', () => { model: undefined, argumentHint: undefined, tools: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, uri: URI.joinPath(rootFolderUri, '.github/agents/full-access-agent.agent.md'), source: { storage: PromptsStorage.local } @@ -1504,7 +1621,7 @@ suite('PromptsService', () => { const copilotSkill = personalSkills.find(s => s.uri.path.includes('.copilot')); assert.ok(copilotSkill, 'Should find copilot personal skill'); - const claudeSkill = personalSkills.find(s => s.uri.path.includes('.claude')); + const claudeSkill = personalSkills.find(s => s.uri.path.includes(CLAUDE_CONFIG_FOLDER)); assert.ok(claudeSkill, 'Should find claude personal skill'); }); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts index a9811b001db48..32809f2ebdd94 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -15,7 +15,7 @@ import { IChatAgentService } from '../../../../common/participants/chatAgents.js import { IChatService } from '../../../../common/chatService/chatService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; -import { ICustomAgent, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ICustomAgent, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; import { MockPromptsService } from '../../promptSyntax/service/mockPromptsService.js'; import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; @@ -55,6 +55,7 @@ suite('RunSubagentTool', () => { tools: ['tool1', 'tool2'], agentInstructions: { content: 'Custom agent body', toolReferences: [] }, source: { storage: PromptsStorage.local }, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; promptsService.setCustomModes([customMode]); @@ -274,6 +275,7 @@ suite('RunSubagentTool', () => { model: modelQualifiedNames, agentInstructions: { content: 'test', toolReferences: [] }, source: { storage: PromptsStorage.local }, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; } diff --git a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts index 0541af5cfca5e..ea248c48bd24e 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts @@ -137,7 +137,7 @@ export class MockLanguageModelToolsService extends Disposable implements ILangua throw new Error('Method not implemented.'); } - toToolAndToolSetEnablementMap(toolOrToolSetNames: readonly string[], target: string | undefined): IToolAndToolSetEnablementMap { + toToolAndToolSetEnablementMap(toolOrToolSetNames: readonly string[]): IToolAndToolSetEnablementMap { throw new Error('Method not implemented.'); }