diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 6d6fbc38a1818..c717f6a1c48f0 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -122,7 +122,7 @@ export const CHAT_CONFIG_MENU_ID = new MenuId('workbench.chat.menu.config'); const OPEN_CHAT_QUOTA_EXCEEDED_DIALOG = 'workbench.action.chat.openQuotaExceededDialog'; abstract class OpenChatGlobalAction extends Action2 { - constructor(overrides: Pick, private readonly mode?: ChatModeKind) { + constructor(overrides: Pick, private readonly mode?: IChatMode) { super({ ...overrides, icon: Codicon.copilot, @@ -160,8 +160,7 @@ abstract class OpenChatGlobalAction extends Action2 { return; } - const switchToModeInput = opts?.mode ?? this.mode; - const switchToMode = switchToModeInput && (chatModeService.findModeById(switchToModeInput) ?? chatModeService.findModeByName(switchToModeInput)); + const switchToMode = (opts?.mode ? chatModeService.findModeByName(opts?.mode) : undefined) ?? this.mode; if (switchToMode) { await this.handleSwitchToMode(switchToMode, chatWidget, instaService, commandService); } @@ -275,9 +274,9 @@ abstract class ModeOpenChatGlobalAction extends OpenChatGlobalAction { constructor(mode: IChatMode, keybinding?: ICommandPaletteOptions['keybinding']) { super({ id: getOpenChatActionIdForMode(mode), - title: localize2('openChatMode', "Open Chat ({0})", mode.name), + title: localize2('openChatMode', "Open Chat ({0})", mode.label), keybinding - }, mode.kind); + }, mode); } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts index a36d58c9996f0..85c0b3f20f6fb 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts @@ -128,7 +128,7 @@ class ConfigureToolsAction extends Action2 { break; case ToolsScope.Mode: placeholder = localize('chat.tools.placeholder.mode', "Select tools for this chat mode"); - description = localize('chat.tools.description.mode', "The selected tools are configured by the '{0}' chat mode. Changes to the tools will be applied to the mode file as well.", widget.input.currentModeObs.get().name); + description = localize('chat.tools.description.mode', "The selected tools are configured by the '{0}' chat mode. Changes to the tools will be applied to the mode file as well.", widget.input.currentModeObs.get().label); break; case ToolsScope.Global: placeholder = localize('chat.tools.placeholder.global', "Select tools that are available to chat."); diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index 039b869fc27a6..193efcbf2be91 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -479,7 +479,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.initSelectedModel(); this._register(this.onDidChangeCurrentChatMode(() => { - this.accessibilityService.alert(this._currentModeObservable.get().name); + this.accessibilityService.alert(this._currentModeObservable.get().label); if (this._inputEditor) { this._inputEditor.updateOptions({ ariaLabel: this._getAriaLabel() }); } diff --git a/src/vs/workbench/contrib/chat/browser/chatSelectedTools.ts b/src/vs/workbench/contrib/chat/browser/chatSelectedTools.ts index a31cde5f7e2f6..6766cee3f840a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSelectedTools.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSelectedTools.ts @@ -82,7 +82,7 @@ export class ChatSelectedTools extends Disposable { const currentMode = this._mode.read(r); - let currentMap = this._sessionStates.get(currentMode.id); + let currentMap = this._sessionStates.observable.read(r).get(currentMode.id); const modeTools = currentMode.customTools?.read(r); if (!currentMap && currentMode.kind === ChatModeKind.Agent && modeTools) { currentMap = this._toolsService.toToolAndToolSetEnablementMap(modeTools); diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index e066be2b8c04f..64abadc4d11bd 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -51,6 +51,7 @@ import { ChatViewModel, IChatRequestViewModel, IChatResponseViewModel, isRequest import { IChatInputState } from '../common/chatWidgetHistoryService.js'; import { CodeBlockModelCollection } from '../common/codeBlockModelCollection.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../common/constants.js'; +import { IChatModeService } from '../common/chatModes.js'; import { ILanguageModelToolsService, IToolData, ToolSet } from '../common/languageModelToolsService.js'; import { type TPromptMetadata } from '../common/promptSyntax/parsers/promptHeader/promptHeader.js'; import { IPromptParserResult, IPromptsService } from '../common/promptSyntax/service/promptsService.js'; @@ -305,7 +306,8 @@ export class ChatWidget extends Disposable implements IChatWidget { @ITelemetryService private readonly telemetryService: ITelemetryService, @IPromptsService private readonly promptsService: IPromptsService, @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, - @IWorkspaceContextService private readonly contextService: IWorkspaceContextService + @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, + @IChatModeService private readonly chatModeService: IChatModeService ) { super(); this._lockedToCodingAgentContextKey = ChatContextKeys.lockedToCodingAgent.bindTo(this.contextKeyService); @@ -2095,16 +2097,24 @@ export class ChatWidget extends Disposable implements IChatWidget { const { mode, tools, model } = metadata; + const currentMode = this.input.currentModeObs.get(); + // switch to appropriate chat mode if needed - if (mode && mode !== this.input.currentModeKind) { - const chatModeCheck = await this.instantiationService.invokeFunction(handleModeSwitch, this.input.currentModeKind, mode, this.viewModel?.model.getRequests().length ?? 0, this.viewModel?.model.editingSession); - if (!chatModeCheck) { - return undefined; - } else if (chatModeCheck.needToClearSession) { - this.clear(); - await this.waitForReady(); + if (mode && mode !== currentMode.name) { + // Find the mode object to get its kind + const chatMode = this.chatModeService.findModeByName(mode); + if (chatMode) { + if (currentMode.kind !== chatMode.kind) { + const chatModeCheck = await this.instantiationService.invokeFunction(handleModeSwitch, currentMode.kind, chatMode.kind, this.viewModel?.model.getRequests().length ?? 0, this.viewModel?.model.editingSession); + if (!chatModeCheck) { + return undefined; + } else if (chatModeCheck.needToClearSession) { + this.clear(); + await this.waitForReady(); + } + } + this.input.setChatMode(chatMode.id); } - this.input.setChatMode(mode); } // if not tools to enable are present, we are done diff --git a/src/vs/workbench/contrib/chat/browser/modelPicker/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/modelPicker/modePickerActionItem.ts index 723ac10fff609..fa8f75f5308e5 100644 --- a/src/vs/workbench/contrib/chat/browser/modelPicker/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/modelPicker/modePickerActionItem.ts @@ -40,7 +40,7 @@ export class ModePickerActionItem extends ActionWidgetDropdownActionViewItem { const makeAction = (mode: IChatMode, currentMode: IChatMode): IActionWidgetDropdownAction => ({ ...action, id: getOpenChatActionIdForMode(mode), - label: mode.name, + label: mode.label, class: undefined, enabled: true, checked: currentMode.id === mode.id, @@ -56,7 +56,7 @@ export class ModePickerActionItem extends ActionWidgetDropdownActionViewItem { const makeActionFromCustomMode = (mode: IChatMode, currentMode: IChatMode): IActionWidgetDropdownAction => ({ ...action, id: getOpenChatActionIdForMode(mode), - label: mode.name, + label: mode.label, class: undefined, enabled: true, checked: currentMode.id === mode.id, @@ -111,7 +111,7 @@ export class ModePickerActionItem extends ActionWidgetDropdownActionViewItem { return null; } this.setAriaLabelAttributes(element); - const state = this.delegate.currentMode.get().name; + const state = this.delegate.currentMode.get().label; dom.reset(element, dom.$('span.chat-model-label', undefined, state), ...renderLabelWithIcons(`$(chevron-down)`)); return null; } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts index e3ce0b0b8d1fa..3be2c2f3538f1 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts @@ -27,6 +27,7 @@ import { ChatContextKeys } from '../../common/chatContextKeys.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { askForPromptFileName } from './pickers/askForPromptName.js'; import { askForPromptSourceFolder } from './pickers/askForPromptSourceFolder.js'; +import { IChatModeService } from '../../common/chatModes.js'; class AbstractNewPromptFileAction extends Action2 { @@ -57,6 +58,7 @@ class AbstractNewPromptFileAction extends Action2 { const editorService = accessor.get(IEditorService); const fileService = accessor.get(IFileService); const instaService = accessor.get(IInstantiationService); + const chatModeService = accessor.get(IChatModeService); const selectedFolder = await instaService.invokeFunction(askForPromptSourceFolder, this.type); if (!selectedFolder) { @@ -81,7 +83,7 @@ class AbstractNewPromptFileAction extends Action2 { if (editor && editor.hasModel() && isEqual(editor.getModel().uri, promptUri)) { SnippetController2.get(editor)?.apply([{ range: editor.getModel().getFullModelRange(), - template: getDefaultContentSnippet(this.type), + template: this.getDefaultContentSnippet(this.type, chatModeService), }]); } @@ -137,37 +139,41 @@ class AbstractNewPromptFileAction extends Action2 { }, ); } -} -function getDefaultContentSnippet(promptType: PromptsType): string { - switch (promptType) { - case PromptsType.prompt: - return [ - `---`, - `mode: \${1|ask,edit,agent|}`, - `---`, - `\${2:Define the task to achieve, including specific requirements, constraints, and success criteria.}`, - ].join('\n'); - case PromptsType.instructions: - return [ - `---`, - `applyTo: '\${1|**,**/*.ts|}'`, - `---`, - `\${2:Provide project context and coding guidelines that AI should follow when generating code, answering questions, or reviewing changes.}`, - ].join('\n'); - case PromptsType.mode: - return [ - `---`, - `description: '\${1:Description of the custom chat mode.}'`, - `tools: []`, - `---`, - `\${2:Define the purpose of this chat mode and how AI should behave: response style, available tools, focus areas, and any mode-specific instructions or constraints.}`, - ].join('\n'); - default: - throw new Error(`Unknown prompt type: ${promptType}`); + private getDefaultContentSnippet(promptType: PromptsType, chatModeService: IChatModeService): string { + const modes = chatModeService.getModes(); + const modeNames = modes.builtin.map(mode => mode.name).join(',') + (modes.custom.length ? (',' + modes.custom.map(mode => mode.name).join(',')) : ''); + switch (promptType) { + case PromptsType.prompt: + return [ + `---`, + `mode: \${1|${modeNames}|}`, + `---`, + `\${2:Define the task to achieve, including specific requirements, constraints, and success criteria.}`, + ].join('\n'); + case PromptsType.instructions: + return [ + `---`, + `applyTo: '\${1|**,**/*.ts|}'`, + `---`, + `\${2:Provide project context and coding guidelines that AI should follow when generating code, answering questions, or reviewing changes.}`, + ].join('\n'); + case PromptsType.mode: + return [ + `---`, + `description: '\${1:Description of the custom chat mode.}'`, + `tools: []`, + `---`, + `\${2:Define the purpose of this chat mode and how AI should behave: response style, available tools, focus areas, and any mode-specific instructions or constraints.}`, + ].join('\n'); + default: + throw new Error(`Unknown prompt type: ${promptType}`); + } } } + + export const NEW_PROMPT_COMMAND_ID = 'workbench.command.new.prompt'; export const NEW_INSTRUCTIONS_COMMAND_ID = 'workbench.command.new.instructions'; export const NEW_MODE_COMMAND_ID = 'workbench.command.new.mode'; diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index 50cf936a2418d..72fb4d23a416a 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -162,25 +162,16 @@ export class ChatModeService extends Disposable implements IChatModeService { getModes(): { builtin: readonly IChatMode[]; custom: readonly IChatMode[] } { return { builtin: this.getBuiltinModes(), - custom: this.chatAgentService.hasToolsAgent ? - Array.from(this._customModeInstances.values()) : - [] + custom: this.getCustomModes(), }; } - private getFlatModes(): IChatMode[] { - const allModes = this.getModes(); - return [...allModes.builtin, ...allModes.custom]; - } - findModeById(id: string | ChatModeKind): IChatMode | undefined { - const allModes = this.getFlatModes(); - return allModes.find(mode => mode.id === id); + return this.getBuiltinModes().find(mode => mode.id === id) ?? this.getCustomModes().find(mode => mode.id === id); } findModeByName(name: string): IChatMode | undefined { - const allModes = this.getFlatModes(); - return allModes.find(mode => mode.name === name); + return this.getBuiltinModes().find(mode => mode.name === name) ?? this.getCustomModes().find(mode => mode.name === name); } private getBuiltinModes(): IChatMode[] { @@ -194,6 +185,10 @@ export class ChatModeService extends Disposable implements IChatModeService { builtinModes.push(ChatMode.Edit); return builtinModes; } + + private getCustomModes(): IChatMode[] { + return this.chatAgentService.hasToolsAgent ? Array.from(this._customModeInstances.values()) : []; + } } export interface IChatModeData { @@ -210,6 +205,7 @@ export interface IChatModeData { export interface IChatMode { readonly id: string; readonly name: string; + readonly label: string; readonly description: IObservable; readonly isBuiltin: boolean; readonly kind: ChatModeKind; @@ -270,6 +266,10 @@ export class CustomChatMode implements IChatMode { return this._uriObservable; } + get label(): string { + return this.name; + } + public readonly kind = ChatModeKind.Agent; constructor( @@ -317,7 +317,7 @@ export class BuiltinChatMode implements IChatMode { constructor( public readonly kind: ChatModeKind, - public readonly name: string, + public readonly label: string, description: string ) { this.description = observableValue('description', description); @@ -332,6 +332,10 @@ export class BuiltinChatMode implements IChatMode { return this.kind; } + get name(): string { + return this.kind; + } + /** * Getters are not json-stringified */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts new file mode 100644 index 0000000000000..785b407da9c20 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { Position } from '../../../../../../editor/common/core/position.js'; +import { Range } from '../../../../../../editor/common/core/range.js'; +import { Definition, DefinitionProvider } from '../../../../../../editor/common/languages.js'; +import { ITextModel } from '../../../../../../editor/common/model.js'; +import { ILanguageFeaturesService } from '../../../../../../editor/common/services/languageFeatures.js'; +import { IChatModeService } from '../../chatModes.js'; +import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId } from '../promptTypes.js'; +import { IPromptsService } from '../service/promptsService.js'; +import { PromptModeMetadata } from '../parsers/promptHeader/metadata/mode.js'; +import { PromptHeader } from '../parsers/promptHeader/promptHeader.js'; + +export class PromptHeaderDefinitionProvider extends Disposable implements DefinitionProvider { + /** + * Debug display name for this provider. + */ + public readonly _debugDisplayName: string = 'PromptHeaderHoverProvider'; + + constructor( + @IPromptsService private readonly promptsService: IPromptsService, + @ILanguageFeaturesService private readonly languageService: ILanguageFeaturesService, + @IChatModeService private readonly chatModeService: IChatModeService, + ) { + super(); + + this._register(this.languageService.definitionProvider.register(ALL_PROMPTS_LANGUAGE_SELECTOR, this)); + } + async provideDefinition(model: ITextModel, position: Position, token: CancellationToken): Promise { + const promptType = getPromptsTypeForLanguageId(model.getLanguageId()); + if (!promptType) { + // if the model is not a prompt, we don't provide any completions + return undefined; + } + + const parser = this.promptsService.getSyntaxParserFor(model); + await parser.start(token).settled(); + + if (token.isCancellationRequested) { + return undefined; + } + + const header = parser.header; + if (!header) { + return undefined; + } + + const completed = await header.settled; + if (!completed || token.isCancellationRequested) { + return undefined; + } + + if (header instanceof PromptHeader) { + const mode = header.metadataUtility.mode; + if (mode?.range.containsPosition(position)) { + return this.getModeDefinition(mode, position); + } + } + return undefined; + } + + + private getModeDefinition(mode: PromptModeMetadata, position: Position): Definition | undefined { + const value = mode.value; + if (value && mode.valueRange?.containsPosition(position)) { + const mode = this.chatModeService.findModeByName(value); + if (mode && mode.uri) { + return { + uri: mode.uri.get(), + range: new Range(1, 1, 1, 1) + }; + } + } + 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 8492d5b2d86d4..92c425f90bbb2 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -13,12 +13,14 @@ import { ITextModel } from '../../../../../../editor/common/model.js'; import { ILanguageFeaturesService } from '../../../../../../editor/common/services/languageFeatures.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService } from '../../languageModelToolsService.js'; +import { IChatModeService } from '../../chatModes.js'; import { InstructionsHeader } from '../parsers/promptHeader/instructionsHeader.js'; import { PromptToolsMetadata } from '../parsers/promptHeader/metadata/tools.js'; import { ModeHeader } from '../parsers/promptHeader/modeHeader.js'; import { PromptHeader } from '../parsers/promptHeader/promptHeader.js'; import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; +import { Iterable } from '../../../../../../base/common/iterator.js'; export class PromptHeaderAutocompletion extends Disposable implements CompletionItemProvider { /** @@ -36,6 +38,7 @@ export class PromptHeaderAutocompletion extends Disposable implements Completion @ILanguageFeaturesService private readonly languageService: ILanguageFeaturesService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, + @IChatModeService private readonly chatModeService: IChatModeService, ) { super(); @@ -211,7 +214,13 @@ export class PromptHeaderAutocompletion extends Disposable implements Completion return ['**', '**/*.ts, **/*.js', '**/*.php', '**/*.py']; } if (promptType === PromptsType.prompt && property === 'mode') { - return ['agent', 'edit', 'ask']; + // Get all available modes (builtin + custom) + const modes = this.chatModeService.getModes(); + const suggestions: string[] = []; + for (const mode of Iterable.concat(modes.builtin, modes.custom)) { + suggestions.push(mode.name); + } + return suggestions; } if (property === 'tools' && (promptType === PromptsType.prompt || promptType === PromptsType.mode)) { return ['[]', `['codebase', 'editFiles', 'fetch']`]; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderDiagnosticsProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderDiagnosticsProvider.ts index 8b599cdfa2bb9..3ce31ef2d3c47 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderDiagnosticsProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderDiagnosticsProvider.ts @@ -19,6 +19,9 @@ import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../langua import { ILanguageModelToolsService } from '../../languageModelToolsService.js'; import { localize } from '../../../../../../nls.js'; import { ChatModeKind } from '../../constants.js'; +import { IChatMode, IChatModeService } from '../../chatModes.js'; +import { PromptModeMetadata } from '../parsers/promptHeader/metadata/mode.js'; +import { Iterable } from '../../../../../../base/common/iterator.js'; /** * Unique ID of the markers provider class. @@ -36,6 +39,7 @@ class PromptHeaderDiagnosticsProvider extends ProviderInstanceBase { @IMarkerService private readonly markerService: IMarkerService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, + @IChatModeService private readonly chatModeService: IChatModeService, ) { super(model, promptsService); this._register(languageModelsService.onDidChangeLanguageModels(() => { @@ -44,6 +48,9 @@ class PromptHeaderDiagnosticsProvider extends ProviderInstanceBase { this._register(languageModelToolsService.onDidChangeTools(() => { this.onPromptSettled(undefined, CancellationToken.None); })); + this._register(chatModeService.onDidChangeChatModes(() => { + this.onPromptSettled(undefined, CancellationToken.None); + })); } /** @@ -73,8 +80,9 @@ class PromptHeaderDiagnosticsProvider extends ProviderInstanceBase { } if (header instanceof PromptHeader) { - this.validateTools(header.metadataUtility.tools, header.metadata.mode, markers); - this.validateModel(header.metadataUtility.model, header.metadata.mode, markers); + const mode = this.validateMode(header.metadataUtility.mode, markers); + this.validateTools(header.metadataUtility.tools, mode?.kind, markers); + this.validateModel(header.metadataUtility.model, mode?.kind, markers); } else if (header instanceof ModeHeader) { this.validateTools(header.metadataUtility.tools, ChatModeKind.Agent, markers); this.validateModel(header.metadataUtility.model, ChatModeKind.Agent, markers); @@ -93,7 +101,7 @@ class PromptHeaderDiagnosticsProvider extends ProviderInstanceBase { ); return; } - validateModel(modelNode: PromptModelMetadata | undefined, modeKind: ChatModeKind | undefined, markers: IMarkerData[]) { + validateModel(modelNode: PromptModelMetadata | undefined, modeKind: string | ChatModeKind | undefined, markers: IMarkerData[]) { if (!modelNode || modelNode.value === undefined) { return; } @@ -128,7 +136,7 @@ class PromptHeaderDiagnosticsProvider extends ProviderInstanceBase { return undefined; } - validateTools(tools: PromptToolsMetadata | undefined, modeKind: ChatModeKind | undefined, markers: IMarkerData[]) { + validateTools(tools: PromptToolsMetadata | undefined, modeKind: string | ChatModeKind | undefined, markers: IMarkerData[]) { if (!tools || tools.value === undefined || modeKind === ChatModeKind.Ask || modeKind === ChatModeKind.Edit) { return; } @@ -155,6 +163,32 @@ class PromptHeaderDiagnosticsProvider extends ProviderInstanceBase { } } + validateMode(modeNode: PromptModeMetadata | undefined, markers: IMarkerData[]): IChatMode | undefined { + if (!modeNode || modeNode.value === undefined) { + return; + } + + const modeValue = modeNode.value; + const modes = this.chatModeService.getModes(); + const availableModes = []; + + // Check if mode exists in builtin or custom modes + for (const mode of Iterable.concat(modes.builtin, modes.custom)) { + if (mode.name === modeValue) { + return mode; + } + availableModes.push(mode.name); // collect all available mode names + } + + markers.push({ + message: localize('promptHeaderDiagnosticsProvider.modeNotFound', "Unknown mode '{0}'. Available modes: {1}", modeValue, availableModes.join(', ')), + severity: MarkerSeverity.Warning, + ...modeNode.range, + }); + return undefined; + + } + /** * Returns a string representation of this object. */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderHovers.ts index a8505faeb8e61..76400e1ea3470 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderHovers.ts @@ -14,12 +14,14 @@ import { ILanguageFeaturesService } from '../../../../../../editor/common/servic import { localize } from '../../../../../../nls.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, ToolSet } from '../../languageModelToolsService.js'; +import { IChatModeService, isBuiltinChatMode } from '../../chatModes.js'; import { InstructionsHeader } from '../parsers/promptHeader/instructionsHeader.js'; import { PromptModelMetadata } from '../parsers/promptHeader/metadata/model.js'; import { PromptToolsMetadata } from '../parsers/promptHeader/metadata/tools.js'; import { ModeHeader } from '../parsers/promptHeader/modeHeader.js'; import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; +import { PromptModeMetadata } from '../parsers/promptHeader/metadata/mode.js'; export class PromptHeaderHoverProvider extends Disposable implements HoverProvider { /** @@ -32,6 +34,7 @@ export class PromptHeaderHoverProvider extends Disposable implements HoverProvid @ILanguageFeaturesService private readonly languageService: ILanguageFeaturesService, @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @IChatModeService private readonly chatModeService: IChatModeService, ) { super(); @@ -95,7 +98,7 @@ export class PromptHeaderHoverProvider extends Disposable implements HoverProvid return this.getModelHover(model, model.range, localize('promptHeader.mode.model', 'The model to use in this mode.')); } const tools = header.metadataUtility.tools; - if (tools?.range?.containsPosition(position)) { + if (tools?.range.containsPosition(position)) { return this.getToolHover(tools, position, localize('promptHeader.mode.tools', 'The tools to use in this mode.')); } } else { @@ -108,12 +111,12 @@ export class PromptHeaderHoverProvider extends Disposable implements HoverProvid return this.getModelHover(model, model.range, localize('promptHeader.prompt.model', 'The model to use in this prompt.')); } const tools = header.metadataUtility.tools; - if (tools?.range?.containsPosition(position)) { + if (tools?.range.containsPosition(position)) { return this.getToolHover(tools, position, localize('promptHeader.prompt.tools', 'The tools to use in this prompt.')); } - const modeRange = header.metadataUtility.mode?.range; - if (modeRange?.containsPosition(position)) { - return this.createHover(localize('promptHeader.prompt.mode', 'The mode (ask, edit or agent) to use when running this prompt.'), modeRange); + const mode = header.metadataUtility.mode; + if (mode?.range.containsPosition(position)) { + return this.getModeHover(mode, position, localize('promptHeader.prompt.mode', 'The mode to use in this prompt.')); } } return undefined; @@ -172,4 +175,39 @@ export class PromptHeaderHoverProvider extends Disposable implements HoverProvid return this.createHover(baseMessage, range); } + private getModeHover(mode: PromptModeMetadata, position: Position, baseMessage: string): Hover | undefined { + const lines: string[] = []; + + + const value = mode.value; + if (value && mode.valueRange?.containsPosition(position)) { + const mode = this.chatModeService.findModeByName(value); + if (mode) { + const description = mode.description.get() || (isBuiltinChatMode(mode) ? localize('promptHeader.prompt.mode.builtInDesc', 'Built-in chat mode') : localize('promptHeader.prompt.mode.customDesc', 'Custom chat mode')); + lines.push(`\`${mode.name}\`: ${description}`); + } + } else { + const modes = this.chatModeService.getModes(); + lines.push(localize('promptHeader.prompt.mode.description', 'The chat mode to use when running this prompt.')); + lines.push(''); + + // Built-in modes + lines.push(localize('promptHeader.prompt.mode.builtin', '**Built-in modes:**')); + for (const mode of modes.builtin) { + lines.push(`- \`${mode.name}\`: ${mode.description.get() || mode.label}`); + } + + // Custom modes + if (modes.custom.length > 0) { + lines.push(''); + lines.push(localize('promptHeader.prompt.mode.custom', '**Custom modes:**')); + for (const mode of modes.custom) { + const description = mode.description.get(); + lines.push(`- \`${mode.name}\`: ${description || localize('promptHeader.prompt.mode.customDesc', 'Custom chat mode')}`); + } + } + } + return this.createHover(lines.join('\n'), mode.range); + } + } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts index 156859802ef22..e564b80129bbc 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/basePromptParser.ts @@ -505,7 +505,8 @@ export class BasePromptParser if (tools !== undefined && mode !== ChatModeKind.Ask && mode !== ChatModeKind.Edit) { result.tools = tools; - result.mode = ChatModeKind.Agent; + // Preserve custom mode if specified, otherwise default to Agent + result.mode = mode || ChatModeKind.Agent; } else if (mode !== undefined) { result.mode = mode; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/base/enum.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/base/enum.ts index 2b572e88ae778..71b7d9e7a4f68 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/base/enum.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/base/enum.ts @@ -67,7 +67,7 @@ export abstract class PromptEnumMetadata< this.valueToken.range, localize( 'prompt.header.metadata.enum.diagnostics.invalid-value', - "The '{0}' metadata must be one of {1}, got '{2}'.", + "The property '{0}' must be one of {1}, got '{2}'.", this.recordName, this.validValues .map((value) => { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/base/string.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/base/string.ts index d240efa069ab8..87423ab25b2f3 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/base/string.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/base/string.ts @@ -8,6 +8,8 @@ import { localize } from '../../../../../../../../../nls.js'; import { PromptMetadataDiagnostic, PromptMetadataError } from '../../diagnostics.js'; import { FrontMatterSequence } from '../../../../codecs/base/frontMatterCodec/tokens/frontMatterSequence.js'; import { FrontMatterRecord, FrontMatterString } from '../../../../codecs/base/frontMatterCodec/tokens/index.js'; +import { Range } from '../../../../../../../../../editor/common/core/range.js'; + /** * Base class for all metadata records with a `string` value. @@ -25,6 +27,10 @@ export abstract class PromptStringMetadata extends PromptMetadataRecord return this.valueToken?.cleanText; } + public get valueRange(): Range | undefined { + return this.valueToken?.range; + } + constructor( expectedRecordName: string, recordToken: FrontMatterRecord, @@ -53,7 +59,7 @@ export abstract class PromptStringMetadata extends PromptMetadataRecord valueToken.range, localize( 'prompt.header.metadata.string.diagnostics.invalid-value-type', - "The '{0}' metadata must be a '{1}', got '{2}'.", + "The property '{0}' must be of type '{1}', got '{2}'.", this.recordName, 'string', valueToken.valueTypeName.toString(), diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/mode.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/mode.ts index cca37bc5e3845..b12728f7e7546 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/mode.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/metadata/mode.ts @@ -3,8 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ChatModeKind } from '../../../../constants.js'; -import { PromptEnumMetadata } from './base/enum.js'; +import { PromptStringMetadata } from './base/string.js'; import { FrontMatterRecord, FrontMatterToken } from '../../../codecs/base/frontMatterCodec/tokens/index.js'; /** @@ -14,14 +13,14 @@ const RECORD_NAME = 'mode'; /** * Prompt `mode` metadata record inside the prompt header. + * Now supports both built-in modes (ask, edit, agent) and custom mode IDs. */ -export class PromptModeMetadata extends PromptEnumMetadata { +export class PromptModeMetadata extends PromptStringMetadata { constructor( recordToken: FrontMatterRecord, languageId: string, ) { super( - [ChatModeKind.Ask, ChatModeKind.Edit, ChatModeKind.Agent], RECORD_NAME, recordToken, languageId, diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/promptHeader.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/promptHeader.ts index 19185ee8961e8..250e838237206 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/promptHeader.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/parsers/promptHeader/promptHeader.ts @@ -6,9 +6,6 @@ import { ChatModeKind } from '../../../constants.js'; import { localize } from '../../../../../../../nls.js'; import { PromptMetadataWarning } from './diagnostics.js'; -import { assert } from '../../../../../../../base/common/assert.js'; -import { assertDefined } from '../../../../../../../base/common/types.js'; - import { HeaderBase, IHeaderMetadata, type TDehydrated } from './headerBase.js'; import { PromptsType } from '../../promptTypes.js'; import { FrontMatterRecord } from '../../codecs/base/frontMatterCodec/tokens/index.js'; @@ -82,64 +79,25 @@ export class PromptHeader extends HeaderBase { return false; } - /** - * Check if value of `tools` and `mode` metadata - * are compatible with each other. - */ - private get toolsAndModeCompatible(): boolean { - const { tools, mode } = this.meta; - - // if 'tools' is not set, then the mode metadata - // can have any value so skip the validation - if (tools === undefined) { - return true; - } - - // if 'mode' is not set or invalid it will be ignored, - // therefore treat it as if it was not set - if (mode?.value === undefined) { - return true; - } - - // when mode is set, valid, and tools are present, - // the only valid value for the mode is 'agent' - return (mode.value === ChatModeKind.Agent); - } - /** * Validate that the `tools` and `mode` metadata are compatible * with each other. If not, add a warning diagnostic. */ private validateToolsAndModeCompatibility(): void { - if (this.toolsAndModeCompatible === true) { - return; - } - const { tools, mode } = this.meta; - - // sanity checks on the behavior of the `toolsAndModeCompatible` getter - assertDefined( - tools, - 'Tools metadata must have been present.', - ); - assertDefined( - mode, - 'Mode metadata must have been present.', - ); - assert( - mode.value !== ChatModeKind.Agent, - 'Mode metadata must not be agent mode.', - ); - - this.issues.push( - new PromptMetadataWarning( - tools.range, - localize( - 'prompt.header.metadata.mode.diagnostics.incompatible-with-tools', - "Tools can only be used when in 'agent' mode, but the mode is set to '{0}'. The tools will be ignored.", - mode.value + const modeValue = mode?.value; + + if (tools !== undefined && (modeValue === ChatModeKind.Edit || modeValue === ChatModeKind.Ask)) { + this.issues.push( + new PromptMetadataWarning( + tools.range, + localize( + 'prompt.header.metadata.mode.diagnostics.incompatible-with-tools', + "Tools can not be used in '{0}' mode and will be ignored.", + modeValue + ), ), - ), - ); + ); + } } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileContributions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileContributions.ts index 4f198cf706e12..176a4f9821008 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileContributions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileContributions.ts @@ -14,6 +14,7 @@ import { isWindows } from '../../../../../base/common/platform.js'; import { PromptPathAutocompletion } from './languageProviders/promptPathAutocompletion.js'; import { PromptHeaderAutocompletion } from './languageProviders/promptHeaderAutocompletion.js'; import { PromptHeaderHoverProvider } from './languageProviders/promptHeaderHovers.js'; +import { PromptHeaderDefinitionProvider } from './languageProviders/PromptHeaderDefinitionProvider.js'; /** @@ -47,6 +48,7 @@ export function registerPromptFileContributions(): void { } registerContribution(PromptHeaderAutocompletion); registerContribution(PromptHeaderHoverProvider); + registerContribution(PromptHeaderDefinitionProvider); registerContribution(ConfigMigration); } 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 96682aa0b1564..577a9e4d1dfb0 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -80,7 +80,7 @@ export interface ICustomChatMode { readonly uri: URI; /** - * Name of the custom chat mode. + * Name of the custom chat mode as used in prompt files or contexts */ readonly name: string; 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 5926d2991d1ab..dece3f980502a 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts @@ -71,7 +71,8 @@ suite('ChatModeService', () => { // Check that Ask mode is always present const askMode = modes.builtin.find(mode => mode.id === ChatModeKind.Ask); assert.ok(askMode); - assert.strictEqual(askMode.name, 'Ask'); + assert.strictEqual(askMode.label, 'Ask'); + assert.strictEqual(askMode.name, 'ask'); assert.strictEqual(askMode.kind, ChatModeKind.Ask); }); @@ -123,6 +124,7 @@ suite('ChatModeService', () => { const testMode = modes.custom[0]; assert.strictEqual(testMode.id, customMode.uri.toString()); assert.strictEqual(testMode.name, customMode.name); + assert.strictEqual(testMode.label, customMode.name); assert.strictEqual(testMode.description.get(), customMode.description); assert.strictEqual(testMode.kind, ChatModeKind.Agent); assert.deepStrictEqual(testMode.customTools?.get(), customMode.tools); @@ -170,6 +172,7 @@ suite('ChatModeService', () => { assert.ok(foundMode); assert.strictEqual(foundMode.id, customMode.uri.toString()); assert.strictEqual(foundMode.name, customMode.name); + assert.strictEqual(foundMode.label, customMode.name); }); test('should update existing custom mode instances when data changes', async () => { diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatModeService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatModeService.ts index 2940d1563afe7..300b1f202fc2e 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatModeService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatModeService.ts @@ -19,15 +19,10 @@ export class MockChatModeService implements IChatModeService { } findModeById(id: string): IChatMode | undefined { - return this.getFlatModes().find(mode => mode.id === id); + return this._modes.builtin.find(mode => mode.id === id) ?? this._modes.custom.find(mode => mode.id === id); } findModeByName(name: string): IChatMode | undefined { - return this.getFlatModes().find(mode => mode.name === name); - } - - private getFlatModes(): IChatMode[] { - const allModes = this.getModes(); - return [...allModes.builtin, ...allModes.custom]; + return this._modes.builtin.find(mode => mode.name === name) ?? this._modes.custom.find(mode => mode.name === name); } } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/parsers/textModelPromptParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/parsers/textModelPromptParser.test.ts index 35f77dfd78e7f..c9e5cb77489cd 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/parsers/textModelPromptParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/parsers/textModelPromptParser.test.ts @@ -104,8 +104,8 @@ class TextModelPromptParserTest extends Disposable { } assert.strictEqual( - expectedReferences.length, references.length, + expectedReferences.length, `[${this.model.uri}] Unexpected number of references.`, ); } @@ -143,9 +143,9 @@ class TextModelPromptParserTest extends Disposable { } assert.strictEqual( - expectedDiagnostics.length, diagnostics.length, - `Expected '${expectedDiagnostics.length}' diagnostic objects, got '${diagnostics.length}'.`, + expectedDiagnostics.length, + `Expected '${expectedDiagnostics.length}' diagnostic objects, got '${diagnostics.length}: ${diagnostics.map(d => d.message).join(', ')}'.`, ); } } @@ -665,7 +665,7 @@ suite('TextModelPromptParser', () => { await test.validateHeaderDiagnostics([ new ExpectedDiagnosticError( new Range(2, 15, 2, 15 + 4), - 'The \'description\' metadata must be a \'string\', got \'boolean\'.', + 'The property \'description\' must be of type \'string\', got \'boolean\'.', ), new ExpectedDiagnosticWarning( new Range(4, 2, 4, 2 + 15), @@ -693,7 +693,7 @@ suite('TextModelPromptParser', () => { ), new ExpectedDiagnosticWarning( new Range(5, 1, 5, 84), - `Tools can only be used when in 'agent' mode, but the mode is set to 'ask'. The tools will be ignored.`, + `Tools can not be used in 'ask' mode and will be ignored.`, ), new ExpectedDiagnosticWarning( new Range(6, 3, 6, 3 + 37), @@ -1175,7 +1175,7 @@ suite('TextModelPromptParser', () => { await test.validateHeaderDiagnostics([ new ExpectedDiagnosticWarning( new Range(2, 1, 2, 38), - 'Tools can only be used when in \'agent\' mode, but the mode is set to \'ask\'. The tools will be ignored.', + `Tools can not be used in 'ask' mode and will be ignored.`, ), ]); }); @@ -1222,7 +1222,7 @@ suite('TextModelPromptParser', () => { await test.validateHeaderDiagnostics([ new ExpectedDiagnosticWarning( new Range(2, 1, 2, 38), - 'Tools can only be used when in \'agent\' mode, but the mode is set to \'edit\'. The tools will be ignored.', + `Tools can not be used in 'edit' mode and will be ignored.`, ), ]); }); @@ -1308,17 +1308,152 @@ suite('TextModelPromptParser', () => { await test.validateHeaderDiagnostics([]); }); - test('invalid mode', async () => { - const value = (randomBoolean()) - ? 'unknown mode ' - : 'unknown'; + test('custom mode with tools', async () => { + const customModeName = 'myCustomMode'; + + const test = createTest( + URI.file('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */"tools: [ 'tool_name3', \"tool_name4\" ] \t\t ", + /* 03 */`mode: ${customModeName}`, + /* 04 */"---", + /* 05 */"The cactus on my desk has a thriving Instagram account.", + ], + PROMPT_LANGUAGE_ID, + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + assert( + metadata?.promptType === PromptsType.prompt, + `Must be a 'prompt' metadata, got '${JSON.stringify(metadata)}'.`, + ); + + const { tools, mode } = metadata; + assertDefined( + tools, + 'Tools metadata must be defined.', + ); + + assert.strictEqual( + mode, + customModeName, + 'Mode metadata must have the custom mode value.', + ); + + // Custom modes are now allowed, so no error expected + await test.validateHeaderDiagnostics([]); + }); + + test('custom mode with spaces in value', async () => { + const customModeId = 'my custom mode with spaces'; + + const test = createTest( + URI.file('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */"tools: [ 'tool1', 'tool2' ]", + /* 03 */`mode: "${customModeId}"`, + /* 04 */"---", + /* 05 */"Test prompt with custom mode that has spaces.", + ], + PROMPT_LANGUAGE_ID, + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + assert( + metadata?.promptType === PromptsType.prompt, + `Must be a 'prompt' metadata, got '${JSON.stringify(metadata)}'.`, + ); + + const { tools, mode } = metadata; + assertDefined( + tools, + 'Tools metadata must be defined.', + ); + + assert.strictEqual( + mode, + customModeId, + 'Mode metadata must preserve custom mode with spaces.', + ); + + // Custom modes are now allowed, so no error expected + await test.validateHeaderDiagnostics([]); + }); + + test('custom mode without tools', async () => { + const customModeId = 'debugHelperMode'; const test = createTest( URI.file('/absolute/folder/and/a/filename.txt'), [ /* 01 */"---", + /* 02 */"description: \"Custom debugging mode\"", + /* 03 */`mode: ${customModeId}`, + /* 04 */"---", + /* 05 */"This is a custom mode without tools.", + ], + PROMPT_LANGUAGE_ID, + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + assert( + metadata?.promptType === PromptsType.prompt, + `Must be a 'prompt' metadata, got '${JSON.stringify(metadata)}'.`, + ); + + const { tools, mode, description } = metadata; + assert.strictEqual( + tools, + undefined, + 'Tools metadata must not be defined.', + ); + + assert.strictEqual( + mode, + customModeId, + 'Mode metadata must have the custom mode value.', + ); + + assert.strictEqual( + description, + 'Custom debugging mode', + 'Description metadata must be preserved.', + ); + + // Custom modes are now allowed, so no error expected + await test.validateHeaderDiagnostics([]); + }); + + test('invalid mode - empty string', async () => { + const test = createTest( + URI.file('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", /* 02 */"tools: [ 'tool_name3', \"tool_name4\" ] \t\t ", - /* 03 */`mode: \t\t${value}`, + /* 03 */"mode: \"\"", /* 04 */"---", /* 05 */"The cactus on my desk has a thriving Instagram account.", ], @@ -1347,16 +1482,239 @@ suite('TextModelPromptParser', () => { assert.strictEqual( mode, ChatModeKind.Agent, - 'Mode metadata must have correct value.', + 'Mode metadata must default to agent when mode is empty string.', + ); + + // Empty string mode should be handled gracefully (no error expected) + await test.validateHeaderDiagnostics([]); + }); + + test('invalid mode - boolean value', async () => { + const test = createTest( + URI.file('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */"tools: [ 'tool_name3', \"tool_name4\" ] \t\t ", + /* 03 */"mode: true", + /* 04 */"---", + /* 05 */"The cactus on my desk has a thriving Instagram account.", + ], + PROMPT_LANGUAGE_ID, ); + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + assert( + metadata?.promptType === PromptsType.prompt, + `Must be a 'prompt' metadata, got '${JSON.stringify(metadata)}'.`, + ); + + const { tools, mode } = metadata; + assertDefined( + tools, + 'Tools metadata must be defined.', + ); + + assert.strictEqual( + mode, + ChatModeKind.Agent, + 'Mode metadata must default to agent when mode is boolean.', + ); + + // Boolean mode value should trigger validation error await test.validateHeaderDiagnostics([ new ExpectedDiagnosticError( - new Range(3, 10, 3, 10 + value.trim().length), - `The 'mode' metadata must be one of 'ask' | 'edit' | 'agent', got '${value.trim()}'.`, + new Range(3, 7, 3, 11), + "The property 'mode' must be of type 'string', got 'boolean'.", ), ]); }); + + test('invalid mode - array value', async () => { + const test = createTest( + URI.file('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */"tools: [ 'tool_name3', \"tool_name4\" ] \t\t ", + /* 03 */"mode: ['array', 'mode']", + /* 04 */"---", + /* 05 */"The cactus on my desk has a thriving Instagram account.", + ], + PROMPT_LANGUAGE_ID, + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + assert( + metadata?.promptType === PromptsType.prompt, + `Must be a 'prompt' metadata, got '${JSON.stringify(metadata)}'.`, + ); + + const { tools, mode } = metadata; + assertDefined( + tools, + 'Tools metadata must be defined.', + ); + + assert.strictEqual( + mode, + ChatModeKind.Agent, + 'Mode metadata must default to agent when mode is array.', + ); + + // Array mode value should trigger validation error + await test.validateHeaderDiagnostics([ + new ExpectedDiagnosticError( + new Range(3, 7, 3, 24), + "The property 'mode' must be of type 'string', got 'array'.", + ), + ]); + }); + + // Test builtin modes to ensure they still work correctly + test('builtin ask mode', async () => { + const test = createTest( + URI.file('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */"description: \"Ask mode test\"", + /* 03 */"mode: ask", + /* 04 */"---", + /* 05 */"This is a builtin ask mode test.", + ], + PROMPT_LANGUAGE_ID, + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + assert( + metadata?.promptType === PromptsType.prompt, + `Must be a 'prompt' metadata, got '${JSON.stringify(metadata)}'.`, + ); + + const { mode, description } = metadata; + assert.strictEqual( + mode, + ChatModeKind.Ask, + 'Mode metadata must be ask.', + ); + + assert.strictEqual( + description, + 'Ask mode test', + 'Description metadata must be preserved.', + ); + + await test.validateHeaderDiagnostics([]); + }); + + test('builtin edit mode', async () => { + const test = createTest( + URI.file('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */"description: \"Edit mode test\"", + /* 03 */"mode: edit", + /* 04 */"---", + /* 05 */"This is a builtin edit mode test.", + ], + PROMPT_LANGUAGE_ID, + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + assert( + metadata?.promptType === PromptsType.prompt, + `Must be a 'prompt' metadata, got '${JSON.stringify(metadata)}'.`, + ); + + const { mode, description } = metadata; + assert.strictEqual( + mode, + ChatModeKind.Edit, + 'Mode metadata must be edit.', + ); + + assert.strictEqual( + description, + 'Edit mode test', + 'Description metadata must be preserved.', + ); + + await test.validateHeaderDiagnostics([]); + }); + + test('builtin agent mode with tools', async () => { + const test = createTest( + URI.file('/absolute/folder/and/a/filename.txt'), + [ + /* 01 */"---", + /* 02 */"description: \"Agent mode test\"", + /* 03 */"mode: agent", + /* 04 */"tools: ['tool1', 'tool2']", + /* 05 */"---", + /* 06 */"This is a builtin agent mode test.", + ], + PROMPT_LANGUAGE_ID, + ); + + await test.allSettled(); + + const { header, metadata } = test.parser; + assertDefined( + header, + 'Prompt header must be defined.', + ); + + assert( + metadata?.promptType === PromptsType.prompt, + `Must be a 'prompt' metadata, got '${JSON.stringify(metadata)}'.`, + ); + + const { mode, tools, description } = metadata; + assert.strictEqual( + mode, + ChatModeKind.Agent, + 'Mode metadata must be agent.', + ); + + assertDefined( + tools, + 'Tools metadata must be defined for agent mode.', + ); + + assert.strictEqual( + description, + 'Agent mode test', + 'Description metadata must be preserved.', + ); + + await test.validateHeaderDiagnostics([]); + }); }); suite('tools is not set', () => { @@ -1401,7 +1759,7 @@ suite('TextModelPromptParser', () => { await test.validateHeaderDiagnostics([ new ExpectedDiagnosticError( new Range(2, 14, 2, 14 + 29), - `The 'description' metadata must be a 'string', got 'array'.`, + `The property 'description' must be of type 'string', got 'array'.`, ), ]); });