diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 474df4e970ba4..547f42e190a65 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -43,7 +43,7 @@ import { IPathService } from '../../../../services/path/common/pathService.js'; import { generateCustomizationDebugReport } from './aiCustomizationDebugPanel.js'; import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; -import { HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; +import { HookType, HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { Schemas } from '../../../../../base/common/network.js'; import { OS } from '../../../../../base/common/platform.js'; @@ -65,6 +65,8 @@ export interface IAICustomizationListItem { readonly description?: string; readonly storage: PromptsStorage; readonly promptType: PromptsType; + /** When set, overrides `storage` for display grouping purposes. */ + readonly groupKey?: string; nameMatches?: IMatch[]; descriptionMatches?: IMatch[]; } @@ -75,7 +77,7 @@ export interface IAICustomizationListItem { interface IGroupHeaderEntry { readonly type: 'group-header'; readonly id: string; - readonly storage: PromptsStorage; + readonly groupKey: string; readonly label: string; readonly icon: ThemeIcon; readonly count: number; @@ -311,7 +313,7 @@ export class AICustomizationListWidget extends Disposable { private allItems: IAICustomizationListItem[] = []; private displayEntries: IListEntry[] = []; private searchQuery: string = ''; - private readonly collapsedGroups = new Set(); + private readonly collapsedGroups = new Set(); private readonly dropdownActionDisposables = this._register(new DisposableStore()); private readonly delayedFilter = new Delayer(200); @@ -827,6 +829,37 @@ export class AICustomizationListWidget extends Disposable { }); } } + + // Also include hooks defined in agent frontmatter (not in sessions window) + // TODO: add this back when Copilot CLI supports this + const agents = !this.workspaceService.isSessionsWindow ? await this.promptsService.getCustomAgents(CancellationToken.None) : []; + for (const agent of agents) { + if (!agent.hooks) { + continue; + } + for (const hookType of Object.values(HookType)) { + const hookCommands = agent.hooks[hookType]; + if (!hookCommands || hookCommands.length === 0) { + continue; + } + const hookMeta = HOOK_METADATA[hookType]; + for (let i = 0; i < hookCommands.length; i++) { + const hook = hookCommands[i]; + const cmdLabel = formatHookCommandLabel(hook, OS); + const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; + items.push({ + id: `${agent.uri.toString()}#hook:${hookType}[${i}]`, + uri: agent.uri, + name: hookMeta?.label ?? hookType, + filename: basename(agent.uri), + description: `${agent.name}: ${truncatedCmd || localize('hookUnset', "(unset)")}`, + storage: agent.source.storage, + groupKey: 'agents', + promptType, + }); + } + } + } } else { // For instructions, fetch prompt files and group by storage const promptFiles = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); @@ -940,15 +973,17 @@ export class AICustomizationListWidget extends Disposable { // Group items by storage const promptType = sectionToPromptType(this.currentSection); const visibleSources = new Set(this.workspaceService.getStorageSourceFilter(promptType).sources); - const groups: { storage: PromptsStorage; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = [ - { storage: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, - { storage: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, - { storage: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, - { storage: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, - ].filter(g => visibleSources.has(g.storage)); + const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = [ + { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, + { groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, + { groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, + { groupKey: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, + { groupKey: 'agents', label: localize('agentsGroup', "Agents"), icon: agentIcon, description: localize('agentsGroupDescription', "Hooks defined in agent files."), items: [] }, + ].filter(g => visibleSources.has(g.groupKey as PromptsStorage) || g.groupKey === 'agents'); for (const item of matchedItems) { - const group = groups.find(g => g.storage === item.storage); + const key = item.groupKey ?? item.storage; + const group = groups.find(g => g.groupKey === key); if (group) { group.items.push(item); } @@ -967,12 +1002,12 @@ export class AICustomizationListWidget extends Disposable { continue; } - const collapsed = this.collapsedGroups.has(group.storage); + const collapsed = this.collapsedGroups.has(group.groupKey); this.displayEntries.push({ type: 'group-header', - id: `group-${group.storage}`, - storage: group.storage, + id: `group-${group.groupKey}`, + groupKey: group.groupKey, label: group.label, icon: group.icon, count: group.items.length, @@ -997,10 +1032,10 @@ export class AICustomizationListWidget extends Disposable { * Toggles the collapsed state of a group. */ private toggleGroup(entry: IGroupHeaderEntry): void { - if (this.collapsedGroups.has(entry.storage)) { - this.collapsedGroups.delete(entry.storage); + if (this.collapsedGroups.has(entry.groupKey)) { + this.collapsedGroups.delete(entry.groupKey); } else { - this.collapsedGroups.add(entry.storage); + this.collapsedGroups.add(entry.groupKey); } this.filterItems(); } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index 805c977bf06e5..244bf9498ef52 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -24,14 +24,14 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { HOOK_METADATA, HOOKS_BY_TARGET, HookType, IHookTypeMeta } from '../../common/promptSyntax/hookTypes.js'; -import { getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js'; +import { formatHookCommandLabel, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js'; import { getCopilotCliHookTypeName, resolveCopilotCliHookType } from '../../common/promptSyntax/hookCopilotCliCompat.js'; import { getHookSourceFormat, HookSourceFormat, buildNewHookEntry } from '../../common/promptSyntax/hookCompatibility.js'; import { getClaudeHookTypeName, resolveClaudeHookType } from '../../common/promptSyntax/hookClaudeCompat.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js'; -import { findHookCommandSelection, parseAllHookFiles, IParsedHook } from './hookUtils.js'; +import { findHookCommandSelection, findHookCommandInYaml, parseAllHookFiles, IParsedHook } from './hookUtils.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; @@ -348,7 +348,8 @@ export async function showConfigureHooksQuickPick( workspaceRootUri, userHome, targetOS, - CancellationToken.None + CancellationToken.None, + { includeAgentHooks: true } ); // Count hooks per type @@ -445,6 +446,10 @@ export async function showConfigureHooksQuickPick( // Filter hooks by the selected type const hooksOfType = hookEntries.filter(h => h.hookType === selectedHookType!.hookType); + // Separate hooks by source + const fileHooks = hooksOfType.filter(h => !h.agentName); + const agentHooks = hooksOfType.filter(h => h.agentName); + // Step 2: Show "Add new hook" + existing hooks of this type const hookItems: (IHookQuickPickItem | IQuickPickSeparator)[] = []; @@ -455,14 +460,14 @@ export async function showConfigureHooksQuickPick( alwaysShow: true }); - // Add existing hooks - if (hooksOfType.length > 0) { + // Add existing file-based hooks + if (fileHooks.length > 0) { hookItems.push({ type: 'separator', label: localize('existingHooks', "Existing Hooks") }); - for (const entry of hooksOfType) { + for (const entry of fileHooks) { const description = labelService.getUriLabel(entry.fileUri, { relative: true }); hookItems.push({ label: entry.commandLabel, @@ -472,6 +477,26 @@ export async function showConfigureHooksQuickPick( } } + // Add agent-defined hooks grouped by agent name + if (agentHooks.length > 0) { + const agentNames = [...new Set(agentHooks.map(h => h.agentName!))]; + for (const agentName of agentNames) { + hookItems.push({ + type: 'separator', + label: localize('agentHooks', "Agent: {0}", agentName) + }); + + for (const entry of agentHooks.filter(h => h.agentName === agentName)) { + const description = labelService.getUriLabel(entry.fileUri, { relative: true }); + hookItems.push({ + label: entry.commandLabel, + description, + hookEntry: entry + }); + } + } + } + // Auto-execute if only "Add new hook" is available (no existing hooks) if (hooksOfType.length === 0) { selectedHook = hookItems[0] as IHookQuickPickItem; @@ -500,22 +525,34 @@ export async function showConfigureHooksQuickPick( const entry = selectedHook.hookEntry; let selection: ITextEditorSelection | undefined; - // Determine the command field name to highlight based on target platform - const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS); - - // Try to find the command field to highlight - if (commandFieldName) { + if (entry.agentName) { + // Agent hook: search the YAML frontmatter for the command try { const content = await fileService.readFile(entry.fileUri); - selection = findHookCommandSelection( - content.value.toString(), - entry.originalHookTypeId, - entry.index, - commandFieldName - ); + const commandText = formatHookCommandLabel(entry.command, targetOS); + if (commandText) { + selection = findHookCommandInYaml(content.value.toString(), commandText); + } } catch { // Ignore errors and just open without selection } + } else { + // File hook: use JSON-based selection finder + const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS); + + if (commandFieldName) { + try { + const content = await fileService.readFile(entry.fileUri); + selection = findHookCommandSelection( + content.value.toString(), + entry.originalHookTypeId, + entry.index, + commandFieldName + ); + } catch { + // Ignore errors and just open without selection + } + } } if (options?.openEditor) { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts index e5eac07cdb7a4..e019aecb93f25 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts @@ -114,6 +114,54 @@ export function findHookCommandSelection(content: string, hookType: string, inde }; } +/** + * Finds the selection range for a hook command string in a YAML/Markdown file + * (e.g., an agent `.md` file with YAML frontmatter). + * + * Searches for the command text within command field lines and selects the value. + * Supports all hook command field keys: command, windows, linux, osx, bash, powershell. + * + * @param content The full file content + * @param commandText The command string to locate + * @returns The selection range, or undefined if not found + */ +export function findHookCommandInYaml(content: string, commandText: string): ITextEditorSelection | undefined { + const commandFieldKeys = ['command', 'windows', 'linux', 'osx', 'bash', 'powershell']; + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trimStart(); + + // Only match lines whose YAML key is a known command field + const matchedKey = commandFieldKeys.find(key => + trimmed.startsWith(`${key}:`) || trimmed.startsWith(`- ${key}:`) + ); + if (!matchedKey) { + continue; + } + + // Search after the colon to avoid matching within the key name itself + const colonIdx = line.indexOf(':'); + const idx = line.indexOf(commandText, colonIdx + 1); + if (idx !== -1) { + // Verify this is a full match (not a substring of a longer command) + const afterIdx = idx + commandText.length; + const charAfter = afterIdx < line.length ? line.charCodeAt(afterIdx) : -1; + // Accept if what follows is end of line, a quote, or whitespace + if (charAfter === -1 || charAfter === 34 /* " */ || charAfter === 39 /* ' */ || charAfter === 32 /* space */ || charAfter === 9 /* tab */) { + return { + startLineNumber: i + 1, + startColumn: idx + 1, + endLineNumber: i + 1, + endColumn: idx + 1 + commandText.length + }; + } + } + } + + return undefined; +} + /** * Parsed hook information. */ @@ -129,11 +177,15 @@ export interface IParsedHook { originalHookTypeId: string; /** If true, this hook is disabled via `disableAllHooks: true` in its file */ disabled?: boolean; + /** If set, this hook came from a custom agent's frontmatter */ + agentName?: string; } export interface IParseAllHookFilesOptions { /** Additional file URIs to parse (e.g., files skipped due to disableAllHooks) */ additionalDisabledFileUris?: readonly URI[]; + /** If true, also collect hooks from custom agent frontmatter */ + includeAgentHooks?: boolean; } /** @@ -227,5 +279,40 @@ export async function parseAllHookFiles( } } + // Collect hooks from custom agents' frontmatter + if (options?.includeAgentHooks) { + const agents = await promptsService.getCustomAgents(token); + for (const agent of agents) { + if (!agent.hooks) { + continue; + } + for (const hookTypeValue of Object.values(HookType)) { + const commands = agent.hooks[hookTypeValue]; + if (!commands || commands.length === 0) { + continue; + } + const hookTypeMeta = HOOK_METADATA[hookTypeValue]; + if (!hookTypeMeta) { + continue; + } + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + const commandLabel = formatHookCommandLabel(command, os) || nls.localize('commands.hook.emptyCommand', '(empty command)'); + parsedHooks.push({ + hookType: hookTypeValue, + hookTypeLabel: hookTypeMeta.label, + command, + commandLabel, + fileUri: agent.uri, + filePath: labelService.getUriLabel(agent.uri, { relative: true }), + index: i, + originalHookTypeId: hookTypeValue, + agentName: agent.name, + }); + } + } + } + } + return parsedHooks; } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 0d1bb0635de2f..656d4ec170221 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -53,7 +53,7 @@ import { ChatMessageRole, IChatMessage, ILanguageModelsService } from '../langua import { ILanguageModelToolsService } from '../tools/languageModelToolsService.js'; import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; -import { ChatRequestHooks } from '../promptSyntax/hookSchema.js'; +import { ChatRequestHooks, mergeHooks } from '../promptSyntax/hookSchema.js'; const serializedChatKey = 'interactive.sessions'; @@ -959,6 +959,20 @@ export class ChatService extends Disposable implements IChatService { this.logService.warn('[ChatService] Failed to collect hooks:', error); } + // Merge hooks from the selected custom agent's frontmatter (if any) + const agentName = options?.modeInfo?.modeInstructions?.name; + if (agentName) { + try { + const agents = await this.promptsService.getCustomAgents(token, model.sessionResource); + const customAgent = agents.find(a => a.name === agentName); + if (customAgent?.hooks) { + collectedHooks = mergeHooks(collectedHooks, customAgent.hooks); + } + } catch (error) { + this.logService.warn('[ChatService] Failed to collect agent hooks:', error); + } + } + const stopWatch = new StopWatch(false); store.add(token.onCancellationRequested(() => { this.trace('sendRequest', `Request for session ${model.sessionResource} was cancelled`); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts index 6776c6a2e282a..9311488d61c7e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts @@ -4,10 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../base/common/uri.js'; -import { toHookType, resolveHookCommand, IHookCommand } from './hookSchema.js'; +import { toHookType, IHookCommand, extractHookCommandsFromItem } from './hookSchema.js'; import { HOOKS_BY_TARGET, HookType } from './hookTypes.js'; import { Target } from './promptTypes.js'; +export { extractHookCommandsFromItem }; + /** * Cached inverse mapping from HookType to Claude hook type name. * Lazily computed on first access. @@ -132,60 +134,4 @@ export function parseClaudeHooks( return { hooks: result, disabledAllHooks: false }; } -/** - * Helper to extract hook commands from an item that could be: - * 1. A direct command object: { type: 'command', command: '...' } - * 2. A nested structure with matcher (Claude style): { matcher: '...', hooks: [{ type: 'command', command: '...' }] } - * - * This allows Copilot format to handle Claude-style entries if pasted. - * Also handles Claude's leniency where 'type' field can be omitted. - */ -export function extractHookCommandsFromItem( - item: unknown, - workspaceRootUri: URI | undefined, - userHome: string -): IHookCommand[] { - if (!item || typeof item !== 'object') { - return []; - } - - const itemObj = item as Record; - const commands: IHookCommand[] = []; - - // Check for nested hooks with matcher (Claude style): { matcher: "...", hooks: [...] } - const nestedHooks = itemObj.hooks; - if (nestedHooks !== undefined && Array.isArray(nestedHooks)) { - for (const nestedHook of nestedHooks) { - if (!nestedHook || typeof nestedHook !== 'object') { - continue; - } - const normalized = normalizeForResolve(nestedHook as Record); - const resolved = resolveHookCommand(normalized, workspaceRootUri, userHome); - if (resolved) { - commands.push(resolved); - } - } - } else { - // Direct command object - const normalized = normalizeForResolve(itemObj); - const resolved = resolveHookCommand(normalized, workspaceRootUri, userHome); - if (resolved) { - commands.push(resolved); - } - } - - return commands; -} -/** - * Normalizes a hook command object for resolving. - * Claude format allows omitting the 'type' field, treating it as 'command'. - * This ensures compatibility when Claude-style hooks are pasted into Copilot format. - */ -function normalizeForResolve(raw: Record): Record { - // If type is missing or already 'command', ensure it's set to 'command' - if (raw.type === undefined || raw.type === 'command') { - return { ...raw, type: 'command' }; - } - return raw; -} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts index 0d69dce53bff9..8025b3ea67598 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts @@ -12,6 +12,7 @@ import { untildify } from '../../../../../base/common/labels.js'; import { OperatingSystem } from '../../../../../base/common/platform.js'; import { HookType, HOOKS_BY_TARGET, HOOK_METADATA } from './hookTypes.js'; import { Target } from './promptTypes.js'; +import { IValue, IMapValue } from './promptFileParser.js'; /** * A single hook command configuration. @@ -46,6 +47,43 @@ export type ChatRequestHooks = { readonly [K in HookType]?: readonly IHookCommand[]; }; +/** + * Merges two sets of hooks by concatenating the command arrays for each hook type. + * Additional hooks are appended after the base hooks. + */ +export function mergeHooks(base: ChatRequestHooks | undefined, additional: ChatRequestHooks): ChatRequestHooks { + if (!base) { + return additional; + } + + const result: Partial> = { ...base }; + for (const hookType of Object.values(HookType)) { + const baseArr = base[hookType]; + const additionalArr = additional[hookType]; + if (additionalArr && additionalArr.length > 0) { + result[hookType] = baseArr ? [...baseArr, ...additionalArr] : additionalArr; + } + } + return result as ChatRequestHooks; +} + +/** + * Descriptions for hook command fields, used by both the JSON schema and the hover provider. + */ +export const HOOK_COMMAND_FIELD_DESCRIPTIONS: Record = { + type: nls.localize('hook.type', 'Must be "command".'), + command: nls.localize('hook.command', 'The command to execute. This is the default cross-platform command.'), + windows: nls.localize('hook.windows', 'Windows-specific command. If specified and running on Windows, this overrides the "command" field.'), + linux: nls.localize('hook.linux', 'Linux-specific command. If specified and running on Linux, this overrides the "command" field.'), + osx: nls.localize('hook.osx', 'macOS-specific command. If specified and running on macOS, this overrides the "command" field.'), + bash: nls.localize('hook.bash', 'Bash command for Linux and macOS.'), + powershell: nls.localize('hook.powershell', 'PowerShell command for Windows.'), + cwd: nls.localize('hook.cwd', 'Working directory for the script (relative to repository root).'), + env: nls.localize('hook.env', 'Additional environment variables that are merged with the existing environment.'), + timeout: nls.localize('hook.timeout', 'Maximum execution time in seconds (default: 30).'), + timeoutSec: nls.localize('hook.timeoutSec', 'Maximum execution time in seconds (default: 10).'), +}; + /** * JSON Schema for GitHub Copilot hook configuration files. * Hooks enable executing custom shell commands at strategic points in an agent's workflow. @@ -67,37 +105,37 @@ const vscodeHookCommandSchema: IJSONSchema = { type: { type: 'string', enum: ['command'], - description: nls.localize('hook.type', 'Must be "command".') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.type }, command: { type: 'string', - description: nls.localize('hook.command', 'The command to execute. This is the default cross-platform command.') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.command }, windows: { type: 'string', - description: nls.localize('hook.windows', 'Windows-specific command. If specified and running on Windows, this overrides the "command" field.') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.windows }, linux: { type: 'string', - description: nls.localize('hook.linux', 'Linux-specific command. If specified and running on Linux, this overrides the "command" field.') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.linux }, osx: { type: 'string', - description: nls.localize('hook.osx', 'macOS-specific command. If specified and running on macOS, this overrides the "command" field.') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.osx }, cwd: { type: 'string', - description: nls.localize('hook.cwd', 'Working directory for the script (relative to repository root).') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.cwd }, env: { type: 'object', additionalProperties: { type: 'string' }, - description: nls.localize('hook.env', 'Additional environment variables that are merged with the existing environment.') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.env }, timeout: { type: 'number', default: 30, - description: nls.localize('hook.timeout', 'Maximum execution time in seconds (default: 30).') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.timeout } } }; @@ -142,29 +180,29 @@ const copilotCliHookCommandSchema: IJSONSchema = { type: { type: 'string', enum: ['command'], - description: nls.localize('hook.type', 'Must be "command".') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.type }, bash: { type: 'string', - description: nls.localize('hook.bash', 'Bash command for Linux and macOS.') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.bash }, powershell: { type: 'string', - description: nls.localize('hook.powershell', 'PowerShell command for Windows.') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.powershell }, cwd: { type: 'string', - description: nls.localize('hook.cwd', 'Working directory for the script (relative to repository root).') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.cwd }, env: { type: 'object', additionalProperties: { type: 'string' }, - description: nls.localize('hook.env', 'Additional environment variables that are merged with the existing environment.') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.env }, timeoutSec: { type: 'number', default: 10, - description: nls.localize('hook.timeoutSec', 'Maximum execution time in seconds (default: 10).') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.timeoutSec } } }; @@ -444,3 +482,155 @@ export function resolveHookCommand(raw: Record, workspaceRootUr ...(normalized.timeout !== undefined && { timeout: normalized.timeout }), }; } + +/** + * Helper to extract hook commands from an item that could be: + * 1. A direct command object: { type: 'command', command: '...' } + * 2. A nested structure with matcher (Claude style): { matcher: '...', hooks: [{ type: 'command', command: '...' }] } + * + * This allows Copilot format to handle Claude-style entries if pasted. + * Also handles Claude's leniency where 'type' field can be omitted. + */ +export function extractHookCommandsFromItem( + item: unknown, + workspaceRootUri: URI | undefined, + userHome: string +): IHookCommand[] { + if (!item || typeof item !== 'object') { + return []; + } + + const itemObj = item as Record; + const commands: IHookCommand[] = []; + + // Check for nested hooks with matcher (Claude style): { matcher: "...", hooks: [...] } + const nestedHooks = itemObj.hooks; + if (nestedHooks !== undefined && Array.isArray(nestedHooks)) { + for (const nestedHook of nestedHooks) { + if (!nestedHook || typeof nestedHook !== 'object') { + continue; + } + const normalized = normalizeForResolve(nestedHook as Record); + const resolved = resolveHookCommand(normalized, workspaceRootUri, userHome); + if (resolved) { + commands.push(resolved); + } + } + } else { + // Direct command object + const normalized = normalizeForResolve(itemObj); + const resolved = resolveHookCommand(normalized, workspaceRootUri, userHome); + if (resolved) { + commands.push(resolved); + } + } + + return commands; +} + +/** + * Normalizes a hook command object for resolving. + * Claude format allows omitting the 'type' field, treating it as 'command'. + * This ensures compatibility when Claude-style hooks are pasted into Copilot format. + */ +function normalizeForResolve(raw: Record): Record { + // If type is missing or already 'command', ensure it's set to 'command' + if (raw.type === undefined || raw.type === 'command') { + return { ...raw, type: 'command' }; + } + return raw; +} + +/** + * Converts an {@link IValue} YAML AST node into a plain JavaScript value + * (string, array, or object) suitable for passing to hook parsing helpers. + */ +function yamlValueToPlain(value: IValue): unknown { + switch (value.type) { + case 'scalar': + return value.value; + case 'sequence': + return value.items.map(yamlValueToPlain); + case 'map': { + const obj: Record = {}; + for (const prop of value.properties) { + obj[prop.key.value] = yamlValueToPlain(prop.value); + } + return obj; + } + } +} + +/** + * Parses hooks from a subagent's YAML frontmatter `hooks` attribute. + * + * Supports two formats for hook entries: + * + * 1. **Direct command** (our format, without matcher): + * ```yaml + * hooks: + * PreToolUse: + * - type: command + * command: "./scripts/validate.sh" + * ``` + * + * 2. **Nested with matcher** (Claude Code format): + * ```yaml + * hooks: + * PreToolUse: + * - matcher: "Bash" + * hooks: + * - type: command + * command: "./scripts/validate.sh" + * ``` + * + * @param hooksMap The raw YAML map value from the `hooks` frontmatter attribute. + * @param workspaceRootUri Workspace root for resolving relative `cwd` paths. + * @param userHome User home directory path for tilde expansion. + * @param target The agent's target, used to resolve hook type names correctly. + * @returns Resolved hooks organized by hook type, ready for use in {@link ChatRequestHooks}. + */ +export function parseSubagentHooksFromYaml( + hooksMap: IMapValue, + workspaceRootUri: URI | undefined, + userHome: string, + target: Target = Target.Undefined, +): ChatRequestHooks { + const result: Record = {}; + const targetHookMap = HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined]; + + for (const prop of hooksMap.properties) { + const hookTypeName = prop.key.value; + + // Resolve hook type name using the target's own map first, then fall back to canonical names + const hookType = targetHookMap[hookTypeName] ?? toHookType(hookTypeName); + if (!hookType) { + continue; + } + + // The value must be a sequence (array of hook entries) + if (prop.value.type !== 'sequence') { + continue; + } + + const commands: IHookCommand[] = []; + + for (const item of prop.value.items) { + // Convert the YAML AST node to a plain object so the existing + // extractHookCommandsFromItem helper can handle both direct + // commands and nested matcher structures. + const plainItem = yamlValueToPlain(item); + const extracted = extractHookCommandsFromItem(plainItem, workspaceRootUri, userHome); + commands.push(...extracted); + } + + if (commands.length > 0) { + if (!result[hookType]) { + result[hookType] = []; + } + result[hookType].push(...commands); + } + } + + return result as ChatRequestHooks; +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptFileAttributes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptFileAttributes.ts index 2e99a13df8223..a57216523c997 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptFileAttributes.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptFileAttributes.ts @@ -167,6 +167,10 @@ export const customAgentAttributes: Record = { type: 'map', description: localize('promptHeader.agent.github', 'GitHub-specific configuration for the agent, such as token permissions.'), }, + [PromptHeaderAttributes.hooks]: { + type: 'map', + description: localize('promptHeader.agent.hooks', 'Lifecycle hooks scoped to this agent. Define hooks that run only while this agent is active.'), + }, }; // Attribute metadata for skill files (`SKILL.md`). 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 14009b90d4498..4884aed9fa863 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -15,10 +15,12 @@ import { IChatModeService } from '../../chatModes.js'; import { getPromptsTypeForLanguageId, PromptsType, Target } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; -import { ISequenceValue, IValue, parseCommaSeparatedList, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; +import { IMapValue, ISequenceValue, IValue, IHeaderAttribute, parseCommaSeparatedList, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; import { getAttributeDefinition, getTarget, getValidAttributeNames, knownClaudeTools, knownGithubCopilotTools, IValueEntry, ClaudeHeaderAttributes, } from './promptFileAttributes.js'; import { localize } from '../../../../../../nls.js'; import { formatArrayValue, getQuotePreference } from '../utils/promptEditHelper.js'; +import { HOOKS_BY_TARGET, HOOK_METADATA } from '../hookTypes.js'; +import { HOOK_COMMAND_FIELD_DESCRIPTIONS } from '../hookSchema.js'; export class PromptHeaderAutocompletion implements CompletionItemProvider { /** @@ -91,6 +93,33 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { const colonPosition = colonIndex !== -1 ? new Position(position.lineNumber, colonIndex + 1) : undefined; if (!colonPosition || position.isBeforeOrEqual(colonPosition)) { + // Check if the position is inside a multi-line attribute (e.g., hooks map). + // In that case, provide value completions for that attribute instead of attribute name completions. + let containingAttribute = header.attributes.find(({ range }) => + range.startLineNumber < position.lineNumber && position.lineNumber <= range.endLineNumber); + if (!containingAttribute) { + // Handle trailing empty lines after a map-valued attribute: + // The YAML parser's range ends at the last parsed child, but logically + // an empty line before the next attribute still belongs to the map. + for (let i = header.attributes.length - 1; i >= 0; i--) { + const attr = header.attributes[i]; + if (attr.range.endLineNumber < position.lineNumber && attr.value.type === 'map') { + const nextAttr = header.attributes[i + 1]; + const nextStartLine = nextAttr ? nextAttr.range.startLineNumber : headerRange.endLineNumber; + if (position.lineNumber < nextStartLine) { + containingAttribute = attr; + } + break; + } + } + } + if (containingAttribute) { + const attrLineText = model.getLineContent(containingAttribute.range.startLineNumber); + const attrColonIndex = attrLineText.indexOf(':'); + if (attrColonIndex !== -1) { + return this.provideValueCompletions(model, position, header, new Position(containingAttribute.range.startLineNumber, attrColonIndex + 1), promptType, containingAttribute); + } + } return this.provideAttributeNameCompletions(model, position, header, colonPosition, promptType); } else if (colonPosition && colonPosition.isBefore(position)) { return this.provideValueCompletions(model, position, header, colonPosition, promptType); @@ -116,6 +145,11 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { if (colonPosition) { return key; } + // For map-valued attributes, insert a snippet with the nested structure + if (key === PromptHeaderAttributes.hooks && promptType === PromptsType.agent && target !== Target.Claude) { + const hookNames = Object.keys(HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined]); + return `${key}:\n \${1|${hookNames.join(',')}|}:\n - type: command\n command: "$2"`; + } const valueSuggestions = this.getValueSuggestions(promptType, key, target); if (valueSuggestions.length > 0) { return `${key}: \${0:${valueSuggestions[0].name}}`; @@ -146,10 +180,11 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { header: PromptHeader, colonPosition: Position, promptType: PromptsType, + preFoundAttribute?: IHeaderAttribute, ): Promise { const suggestions: CompletionItem[] = []; const posLineNumber = position.lineNumber; - const attribute = header.attributes.find(({ range }) => range.startLineNumber <= posLineNumber && posLineNumber <= range.endLineNumber); + const attribute = preFoundAttribute ?? header.attributes.find(({ range }) => range.startLineNumber <= posLineNumber && posLineNumber <= range.endLineNumber); if (!attribute) { return undefined; } @@ -200,6 +235,18 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { }); } } + if (attribute.key === PromptHeaderAttributes.hooks) { + if (attribute.value.type === 'map') { + // Inside the hooks map — suggest hook event type names as sub-keys + return this.provideHookEventCompletions(model, position, attribute.value, target); + } + // When hooks value is not yet a map (e.g., user is mid-edit on a nested line), + // still provide hook event completions with no existing keys. + if (position.lineNumber !== attribute.range.startLineNumber) { + const emptyMap: IMapValue = { type: 'map', properties: [], range: attribute.value.range }; + return this.provideHookEventCompletions(model, position, emptyMap, target); + } + } const lineContent = model.getLineContent(attribute.range.startLineNumber); const whilespaceAfterColon = (lineContent.substring(colonPosition.column).match(/^\s*/)?.[0].length) ?? 0; const entries = this.getValueSuggestions(promptType, attribute.key, target); @@ -229,9 +276,290 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { }; suggestions.push(item); } + if (attribute.key === PromptHeaderAttributes.hooks && promptType === PromptsType.agent) { + const hookSnippet = [ + '', + ' ${1|' + Object.keys(HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined]).join(',') + '|}:', + ' - type: command', + ' command: "$2"' + ].join('\n'); + const item: CompletionItem = { + label: localize('promptHeaderAutocompletion.newHook', "New Hook"), + kind: CompletionItemKind.Snippet, + insertText: whilespaceAfterColon === 0 ? ` ${hookSnippet}` : hookSnippet, + insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet, + range: new Range(position.lineNumber, colonPosition.column + whilespaceAfterColon + 1, position.lineNumber, model.getLineMaxColumn(position.lineNumber)), + }; + suggestions.push(item); + } return { suggestions }; } + /** + * Provides completions inside the `hooks:` map. + * Determines what to suggest based on nesting depth: + * - At hook event level: suggest event names (SessionStart, PreToolUse, etc.) + * - Inside a command object: suggest command fields (type, command, timeout, etc.) + */ + private provideHookEventCompletions( + model: ITextModel, + position: Position, + hooksMap: IMapValue, + target: Target, + ): CompletionList | undefined { + // Check if the cursor is on the value side of an existing hook event key (e.g., "SessionEnd:|") + // In that case, offer a command entry snippet instead of event name completions. + const hookEventOnLine = hooksMap.properties.find(p => p.key.range.startLineNumber === position.lineNumber); + if (hookEventOnLine) { + const lineText = model.getLineContent(position.lineNumber); + const colonIdx = lineText.indexOf(':'); + if (colonIdx !== -1 && position.column > colonIdx + 1) { + const whilespaceAfterColon = (lineText.substring(colonIdx + 1).match(/^\s*/)?.[0].length) ?? 0; + const commandSnippet = [ + '', + ' - type: command', + ' command: "$1"', + ].join('\n'); + return { + suggestions: [{ + label: localize('promptHeaderAutocompletion.newCommand', "New Command"), + documentation: localize('promptHeaderAutocompletion.newCommand.description', "Add a new command entry to this hook."), + kind: CompletionItemKind.Snippet, + insertText: whilespaceAfterColon === 0 ? ` ${commandSnippet}` : commandSnippet, + insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet, + range: new Range(position.lineNumber, colonIdx + 1 + whilespaceAfterColon + 1, position.lineNumber, model.getLineMaxColumn(position.lineNumber)), + }] + }; + } + } + + // Try to provide command field completions if cursor is inside a command object + const commandFieldCompletions = this.provideHookCommandFieldCompletions(model, position, hooksMap, target); + if (commandFieldCompletions) { + return commandFieldCompletions; + } + + // Otherwise provide hook event name completions + const suggestions: CompletionItem[] = []; + const hooksByTarget = HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined]; + + const lineText = model.getLineContent(position.lineNumber); + const firstNonWhitespace = lineText.search(/\S/); + const isEmptyLine = firstNonWhitespace === -1; + // Start the range after leading whitespace so VS Code's completion + // filtering matches the hook name prefix the user has typed. + const rangeStartColumn = isEmptyLine ? position.column : firstNonWhitespace + 1; + + // Exclude hook keys on the current line so the user sees all options while editing a key + const existingKeys = new Set( + hooksMap.properties + .filter(p => p.key.range.startLineNumber !== position.lineNumber) + .map(p => p.key.value) + ); + + // Supplement with text-based scanning: when incomplete YAML causes the + // parser to drop subsequent keys, scan the model for lines that look + // like hook event entries (e.g., " UserPromptSubmit:") at the expected + // indentation. + const expectedIndent = hooksMap.properties.length > 0 + ? hooksMap.properties[0].key.range.startColumn - 1 + : -1; + if (expectedIndent >= 0) { + const scanEnd = model.getLineCount(); + for (let lineNum = hooksMap.range.endLineNumber + 1; lineNum <= scanEnd; lineNum++) { + if (lineNum === position.lineNumber) { + continue; + } + const lt = model.getLineContent(lineNum); + const lineIndent = lt.search(/\S/); + if (lineIndent === -1) { + continue; + } + if (lineIndent < expectedIndent) { + break; // Left the hooks map scope + } + if (lineIndent === expectedIndent) { + const match = lt.match(/^\s+(\S+)\s*:/); + if (match) { + existingKeys.add(match[1]); + } + } + } + } + + // Check whether the current line already has a colon (editing an existing key) + const lineHasColon = lineText.indexOf(':') !== -1; + + for (const [hookName, hookType] of Object.entries(hooksByTarget)) { + if (existingKeys.has(hookName)) { + continue; + } + const meta = HOOK_METADATA[hookType]; + let insertText: string; + if (isEmptyLine) { + // On empty lines, insert a full hook snippet with command placeholder + insertText = [ + `${hookName}:`, + ` - type: command`, + ` command: "$1"`, + ].join('\n'); + } else if (lineHasColon) { + // On existing key lines, only replace the key name to preserve nested content + insertText = `${hookName}:`; + } else { + // Typing a new event name — omit the colon so the user can + // trigger the next completion (e.g., New Command snippet) by typing ':' + insertText = hookName; + } + suggestions.push({ + label: hookName, + documentation: meta?.description, + kind: CompletionItemKind.Property, + insertText, + insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet, + range: new Range(position.lineNumber, rangeStartColumn, position.lineNumber, model.getLineMaxColumn(position.lineNumber)), + }); + } + + return { suggestions }; + } + + /** + * Provides completions for hook command fields (type, command, windows, etc.) + * when the cursor is inside a command object within the hooks map. + * Detects nesting by checking if the position falls within a sequence item + * of a hook event's value. + */ + private provideHookCommandFieldCompletions( + model: ITextModel, + position: Position, + hooksMap: IMapValue, + target: Target, + ): CompletionList | undefined { + // Find which hook event's command list the cursor is in + const containingCommandMap = this.findContainingCommandMap(model, position, hooksMap); + if (!containingCommandMap) { + return undefined; + } + + const isCopilotCli = target === Target.GitHubCopilot; + const validFields = isCopilotCli + ? ['type', 'bash', 'powershell', 'cwd', 'env', 'timeoutSec'] + : ['type', 'command', 'windows', 'linux', 'osx', 'bash', 'powershell', 'cwd', 'env', 'timeout']; + + const existingFields = new Set( + containingCommandMap.properties + .filter(p => p.key.range.startLineNumber !== position.lineNumber) + .map(p => p.key.value) + ); + + const lineText = model.getLineContent(position.lineNumber); + const firstNonWhitespace = lineText.search(/\S/); + const isEmptyLine = firstNonWhitespace === -1; + // Skip past the YAML sequence indicator `- ` so the range starts at the + // actual field name; otherwise VS Code's completion filter would see the + // `- ` prefix and reject valid field names. + const dashPrefixMatch = lineText.match(/^(\s*-\s+)/); + const fieldStart = dashPrefixMatch ? dashPrefixMatch[1].length : firstNonWhitespace; + const rangeStartColumn = isEmptyLine ? position.column : fieldStart + 1; + const colonIndex = lineText.indexOf(':'); + + const suggestions: CompletionItem[] = []; + for (const fieldName of validFields) { + if (existingFields.has(fieldName)) { + continue; + } + const desc = HOOK_COMMAND_FIELD_DESCRIPTIONS[fieldName]; + const insertText = colonIndex !== -1 ? fieldName : `${fieldName}: $0`; + suggestions.push({ + label: fieldName, + documentation: desc, + kind: CompletionItemKind.Property, + insertText, + insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet, + range: new Range(position.lineNumber, rangeStartColumn, position.lineNumber, colonIndex !== -1 ? colonIndex + 1 : model.getLineMaxColumn(position.lineNumber)), + }); + } + + return suggestions.length > 0 ? { suggestions } : undefined; + } + + /** + * Walks the hooks map AST to find the command map object containing the position. + * Handles both direct command objects and nested matcher format. + * Also handles trailing lines after the last parsed property of a command map. + */ + private findContainingCommandMap(model: ITextModel, position: Position, hooksMap: IMapValue): IMapValue | undefined { + for (let i = 0; i < hooksMap.properties.length; i++) { + const prop = hooksMap.properties[i]; + if (prop.value.type !== 'sequence') { + continue; + } + // Check if cursor is within the sequence's range, or on a trailing line after it + const seqRange = prop.value.range; + const nextProp = hooksMap.properties[i + 1]; + const isInSeq = seqRange.containsPosition(position); + const isTrailingSeq = !isInSeq + && seqRange.endLineNumber < position.lineNumber + && (!nextProp || nextProp.key.range.startLineNumber > position.lineNumber); + + if (isInSeq || isTrailingSeq) { + // For trailing lines, verify the cursor is indented deeper than + // the hook event key — otherwise it belongs to the parent map. + if (isTrailingSeq) { + const lineText = model.getLineContent(position.lineNumber); + const firstNonWs = lineText.search(/\S/); + const effectiveIndent = firstNonWs === -1 ? position.column - 1 : firstNonWs; + const hookKeyIndent = prop.key.range.startColumn - 1; + if (effectiveIndent <= hookKeyIndent) { + continue; + } + } + const result = this.findCommandMapInSequence(position, prop.value); + if (result) { + return result; + } + } + } + return undefined; + } + + private findCommandMapInSequence(position: Position, sequence: ISequenceValue): IMapValue | undefined { + for (let i = 0; i < sequence.items.length; i++) { + const item = sequence.items[i]; + if (item.type !== 'map') { + // Handle partial typing: a scalar on the cursor line means the user + // is starting to type a command entry (e.g., "- t"). + if (item.type === 'scalar' && item.range.startLineNumber === position.lineNumber) { + return { type: 'map', properties: [], range: item.range }; + } + continue; + } + + // Check if position is within or just after this map item's parsed range. + // The parser's range may not include a trailing line being typed. + const isInRange = item.range.containsPosition(position); + const isTrailing = !isInRange + && item.range.endLineNumber < position.lineNumber + && (i + 1 >= sequence.items.length || sequence.items[i + 1].range.startLineNumber > position.lineNumber); + + if (!isInRange && !isTrailing) { + continue; + } + + // Check for nested matcher format: { hooks: [...] } + const nestedHooks = item.properties.find(p => p.key.value === 'hooks'); + if (nestedHooks?.value.type === 'sequence') { + const result = this.findCommandMapInSequence(position, nestedHooks.value); + if (result) { + return result; + } + } + return item; + } + return undefined; + } + private getValueSuggestions(promptType: PromptsType, attribute: string, target: Target): readonly IValueEntry[] { const attributeDesc = getAttributeDefinition(attribute, promptType, target); if (attributeDesc?.enums) { 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 273ceef3be047..00065d1b40c3a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts @@ -15,8 +15,10 @@ import { ILanguageModelToolsService, isToolSet, IToolSet } from '../../tools/lan import { IChatModeService, isBuiltinChatMode } from '../../chatModes.js'; import { getPromptsTypeForLanguageId, PromptsType, Target } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; -import { IHeaderAttribute, parseCommaSeparatedList, PromptBody, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; +import { IHeaderAttribute, ISequenceValue, parseCommaSeparatedList, PromptBody, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; import { ClaudeHeaderAttributes, getAttributeDefinition, getTarget, isVSCodeOrDefaultTarget, knownClaudeModels, knownClaudeTools } from './promptFileAttributes.js'; +import { HOOKS_BY_TARGET, HOOK_METADATA } from '../hookTypes.js'; +import { HOOK_COMMAND_FIELD_DESCRIPTIONS } from '../hookSchema.js'; export class PromptHoverProvider implements HoverProvider { /** @@ -86,6 +88,8 @@ export class PromptHoverProvider implements HoverProvider { return this.getAgentHover(attribute, position, description); case PromptHeaderAttributes.handOffs: return this.getHandsOffHover(attribute, position, target); + case PromptHeaderAttributes.hooks: + return this.getHooksHover(attribute, position, description, target); case PromptHeaderAttributes.infer: return this.createHover(description + '\n\n' + localize('promptHeader.attribute.infer.hover', 'Deprecated: Use `user-invocable` and `disable-model-invocation` instead.'), attribute.range); default: @@ -232,6 +236,62 @@ export class PromptHoverProvider implements HoverProvider { return this.createHover(lines.join('\n'), agentAttribute.range); } + private getHooksHover(attribute: IHeaderAttribute, position: Position, baseMessage: string, target: Target): Hover | undefined { + const value = attribute.value; + if (value.type === 'map') { + const hooksByTarget = HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined]; + for (const prop of value.properties) { + // Hover on a hook event name key (e.g., SessionStart, PreToolUse) + if (prop.key.range.containsPosition(position)) { + const hookType = hooksByTarget[prop.key.value]; + if (hookType) { + const meta = HOOK_METADATA[hookType]; + return this.createHover(`**${meta.label}**\n\n${meta.description}`, prop.key.range); + } + } + // Hover inside hook command entries + if (prop.value.type === 'sequence') { + const hover = this.getHookCommandItemHover(prop.value, position); + if (hover) { + return hover; + } + } + } + } + return this.createHover(baseMessage, attribute.range); + } + + /** + * Recursively searches hook command items for hover information. + * Handles both direct command objects and nested matcher format + * (e.g., `{ matcher: "...", hooks: [{ type: command, ... }] }`). + */ + private getHookCommandItemHover(sequence: ISequenceValue, position: Position): Hover | undefined { + for (const item of sequence.items) { + if (item.type !== 'map' || !item.range.containsPosition(position)) { + continue; + } + // Check for nested matcher format: { hooks: [...] } + const nestedHooks = item.properties.find(p => p.key.value === 'hooks'); + if (nestedHooks && nestedHooks.value.type === 'sequence') { + const hover = this.getHookCommandItemHover(nestedHooks.value, position); + if (hover) { + return hover; + } + } + // Check fields of the command object itself + for (const field of item.properties) { + if (field.key.range.containsPosition(position) || field.value.range.containsPosition(position)) { + const desc = HOOK_COMMAND_FIELD_DESCRIPTIONS[field.key.value]; + if (desc) { + return this.createHover(desc, field.key.range); + } + } + } + } + return undefined; + } + private getHandsOffHover(attribute: IHeaderAttribute, position: Position, target: Target): Hover | undefined { const handoffsBaseMessage = getAttributeDefinition(PromptHeaderAttributes.handOffs, PromptsType.agent, target)?.description!; if (!isVSCodeOrDefaultTarget(target)) { 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 569e024d3c866..d6e71491d019d 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -28,6 +28,7 @@ import { Lazy } from '../../../../../../base/common/lazy.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { dirname } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { HOOKS_BY_TARGET } from '../hookTypes.js'; import { GithubPromptHeaderAttributes } from './promptFileAttributes.js'; export const MARKERS_OWNER_ID = 'prompts-diagnostics-provider'; @@ -191,6 +192,7 @@ export class PromptValidator { this.validateUserInvokable(attributes, report); this.validateDisableModelInvocation(attributes, report); this.validateTools(attributes, ChatModeKind.Agent, target, report); + this.validateHooks(attributes, target, report); if (isVSCodeOrDefaultTarget(target)) { this.validateModel(attributes, ChatModeKind.Agent, report); this.validateHandoffs(attributes, report); @@ -545,6 +547,119 @@ export class PromptValidator { } } + private validateHooks(attributes: IHeaderAttribute[], target: Target, report: (markers: IMarkerData) => void): undefined { + const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.hooks); + if (!attribute) { + return; + } + if (attribute.value.type !== 'map') { + report(toMarker(localize('promptValidator.hooksMustBeMap', "The 'hooks' attribute must be a map of hook event types to command arrays."), attribute.value.range, MarkerSeverity.Error)); + return; + } + const validHookNames = new Set(Object.keys(HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined])); + for (const prop of attribute.value.properties) { + if (!validHookNames.has(prop.key.value)) { + report(toMarker(localize('promptValidator.unknownHookType', "Unknown hook event type '{0}'. Supported: {1}.", prop.key.value, Array.from(validHookNames).join(', ')), prop.key.range, MarkerSeverity.Warning)); + } + if (prop.value.type !== 'sequence') { + report(toMarker(localize('promptValidator.hookValueMustBeArray', "Hook event '{0}' must have an array of command objects as its value.", prop.key.value), prop.value.range, MarkerSeverity.Error)); + continue; + } + for (const item of prop.value.items) { + this.validateHookCommand(item, target, report); + } + } + } + + private validateHookCommand(item: IValue, target: Target, report: (markers: IMarkerData) => void): void { + if (item.type !== 'map') { + report(toMarker(localize('promptValidator.hookCommandMustBeObject', "Each hook command must be an object."), item.range, MarkerSeverity.Error)); + return; + } + + // Detect nested matcher format: { matcher?: "...", hooks: [{ type: 'command', command: '...' }] } + const hooksProperty = item.properties.find(p => p.key.value === 'hooks'); + if (hooksProperty) { + // Validate that only known matcher properties are present + for (const prop of item.properties) { + if (prop.key.value !== 'hooks' && prop.key.value !== 'matcher') { + report(toMarker(localize('promptValidator.unknownMatcherProperty', "Unknown property '{0}' in hook matcher.", prop.key.value), prop.key.range, MarkerSeverity.Warning)); + } + } + if (hooksProperty.value.type !== 'sequence') { + report(toMarker(localize('promptValidator.nestedHooksMustBeArray', "The 'hooks' property in a matcher must be an array of command objects."), hooksProperty.value.range, MarkerSeverity.Error)); + return; + } + for (const nestedItem of hooksProperty.value.items) { + this.validateHookCommand(nestedItem, target, report); + } + return; + } + + const isCopilotCli = target === Target.GitHubCopilot; + + // Determine valid and command-providing properties based on target + const validCommandFields = isCopilotCli + ? new Set(['bash', 'powershell']) + : new Set(['command', 'windows', 'linux', 'osx', 'bash', 'powershell']); + + const validProperties = isCopilotCli + ? new Set(['type', 'bash', 'powershell', 'cwd', 'env', 'timeoutSec']) + : new Set(['type', 'command', 'windows', 'linux', 'osx', 'bash', 'powershell', 'cwd', 'env', 'timeout']); + + let hasType = false; + let hasCommandField = false; + + for (const prop of item.properties) { + const key = prop.key.value; + + if (!validProperties.has(key)) { + report(toMarker(localize('promptValidator.unknownHookProperty', "Unknown property '{0}' in hook command.", key), prop.key.range, MarkerSeverity.Warning)); + } + + if (key === 'type') { + hasType = true; + if (prop.value.type !== 'scalar' || prop.value.value !== 'command') { + report(toMarker(localize('promptValidator.hookTypeMustBeCommand', "The 'type' property in a hook command must be 'command'."), prop.value.range, MarkerSeverity.Error)); + } + } else if (validCommandFields.has(key)) { + hasCommandField = true; + if (prop.value.type !== 'scalar' || prop.value.value.trim().length === 0) { + report(toMarker(localize('promptValidator.hookCommandFieldMustBeNonEmptyString', "The '{0}' property in a hook command must be a non-empty string.", key), prop.value.range, MarkerSeverity.Error)); + } + } else if (key === 'cwd') { + if (prop.value.type !== 'scalar') { + report(toMarker(localize('promptValidator.hookCwdMustBeString', "The 'cwd' property in a hook command must be a string."), prop.value.range, MarkerSeverity.Error)); + } + } else if (key === 'env') { + if (prop.value.type !== 'map') { + report(toMarker(localize('promptValidator.hookEnvMustBeMap', "The 'env' property in a hook command must be a map of string values."), prop.value.range, MarkerSeverity.Error)); + } else { + for (const envProp of prop.value.properties) { + if (envProp.value.type !== 'scalar') { + report(toMarker(localize('promptValidator.hookEnvValueMustBeString', "Environment variable '{0}' must have a string value.", envProp.key.value), envProp.value.range, MarkerSeverity.Error)); + } + } + } + } else if (key === 'timeout' || key === 'timeoutSec') { + if (prop.value.type !== 'scalar' || isNaN(Number(prop.value.value))) { + report(toMarker(localize('promptValidator.hookTimeoutMustBeNumber', "The '{0}' property in a hook command must be a number.", key), prop.value.range, MarkerSeverity.Error)); + } + } + } + + if (!hasType) { + report(toMarker(localize('promptValidator.hookMissingType', "Hook command is missing required property 'type'."), item.range, MarkerSeverity.Error)); + } + if (!hasCommandField) { + if (isCopilotCli) { + report(toMarker(localize('promptValidator.hookMissingCopilotCommand', "Hook command must specify at least one of 'bash' or 'powershell'."), item.range, MarkerSeverity.Error)); + } else { + report(toMarker(localize('promptValidator.hookMissingCommand', "Hook command must specify at least one of 'command', 'windows', 'linux', or 'osx'."), item.range, MarkerSeverity.Error)); + } + } + } + private validateHandoffs(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.handOffs); if (!attribute) { @@ -761,7 +876,7 @@ function isTrueOrFalse(value: IValue): boolean { const allAttributeNames: Record = { [PromptsType.prompt]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.mode, PromptHeaderAttributes.agent, PromptHeaderAttributes.argumentHint], [PromptsType.instructions]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.applyTo, PromptHeaderAttributes.excludeAgent], - [PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer, PromptHeaderAttributes.agents, PromptHeaderAttributes.userInvocable, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation, GithubPromptHeaderAttributes.github], + [PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer, PromptHeaderAttributes.agents, PromptHeaderAttributes.hooks, PromptHeaderAttributes.userInvocable, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation, GithubPromptHeaderAttributes.github], [PromptsType.skill]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.license, PromptHeaderAttributes.compatibility, PromptHeaderAttributes.metadata, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.userInvocable, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation], [PromptsType.hook]: [], // hooks are JSON files, not markdown with YAML frontmatter }; @@ -846,6 +961,8 @@ export function getAttributeDescription(attributeName: string, promptType: Promp return localize('promptHeader.agent.infer', 'Controls visibility of the agent.'); case PromptHeaderAttributes.agents: return localize('promptHeader.agent.agents', 'One or more agents that this agent can use as subagents. Use \'*\' to specify all available agents.'); + case PromptHeaderAttributes.hooks: + return localize('promptHeader.agent.hooks', 'Lifecycle hooks scoped to this agent. Define hooks that run only while this agent is active.'); case PromptHeaderAttributes.userInvocable: return localize('promptHeader.agent.userInvocable', 'Whether the agent can be selected and invoked by users in the UI.'); case PromptHeaderAttributes.disableModelInvocation: diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts index 3d04a7cfec313..240265152cffc 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts @@ -84,6 +84,7 @@ export namespace PromptHeaderAttributes { export const userInvokable = 'user-invokable'; export const userInvocable = 'user-invocable'; export const disableModelInvocation = 'disable-model-invocation'; + export const hooks = 'hooks'; } export class PromptHeader { @@ -317,6 +318,20 @@ export class PromptHeader { return this.getBooleanAttribute(PromptHeaderAttributes.disableModelInvocation); } + /** + * Gets the raw 'hooks' attribute value from the header. + * Returns the YAML map value if present, or undefined. The caller is + * responsible for converting this to `ChatRequestHooks` via + * {@link parseSubagentHooksFromYaml}. + */ + public get hooksRaw(): IMapValue | undefined { + const attr = this._parsedHeader.attributes.find(a => a.key === PromptHeaderAttributes.hooks); + if (attr?.value.type === 'map') { + return attr.value; + } + return undefined; + } + private getBooleanAttribute(key: string): boolean | undefined { const attribute = this._parsedHeader.attributes.find(attr => attr.key === key); if (attribute?.value.type === 'scalar') { 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 b4de6bbc2e4c9..3e04c2fed58ed 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -225,6 +225,11 @@ export interface ICustomAgent { */ readonly agents?: readonly string[]; + /** + * Lifecycle hooks scoped to this subagent. + */ + readonly hooks?: ChatRequestHooks; + /** * Where the agent was loaded from. */ 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 c628f10fcf7ff..688ad88d715ca 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -36,7 +36,7 @@ import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../p import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPluginPromptPath, 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, IPromptSourceFolderResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, IPromptDiscoveryLogEntry } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; -import { ChatRequestHooks, IHookCommand } from '../hookSchema.js'; +import { ChatRequestHooks, IHookCommand, parseSubagentHooksFromYaml } from '../hookSchema.js'; import { HookType } from '../hookTypes.js'; import { HookSourceFormat, getHookSourceFormat, parseHooksFromFile } from '../hookCompatibility.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; @@ -678,6 +678,12 @@ export class PromptsService extends Disposable implements IPromptsService { let agentFiles = await this.listPromptFiles(PromptsType.agent, token); const disabledAgents = this.getDisabledPromptFiles(PromptsType.agent); agentFiles = agentFiles.filter(promptPath => !disabledAgents.has(promptPath.uri)); + + // Get user home for tilde expansion in hook cwd paths + const userHomeUri = await this.pathService.userHome(); + const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; + const defaultFolder = this.workspaceService.getWorkspace().folders[0]; + const customAgentsResults = await Promise.allSettled( agentFiles.map(async (promptPath): Promise => { const uri = promptPath.uri; @@ -733,7 +739,17 @@ export class PromptsService extends Disposable implements IPromptsService { if (target === Target.Claude && tools) { tools = mapClaudeTools(tools); } - return { uri, name, description, model, tools, handOffs, argumentHint, target, visibility, agents, agentInstructions, source }; + + // Parse hooks from the frontmatter if present + let hooks: ChatRequestHooks | undefined; + const hooksRaw = ast.header.hooksRaw; + if (hooksRaw) { + const hookWorkspaceFolder = this.workspaceService.getWorkspaceFolder(uri) ?? defaultFolder; + const workspaceRootUri = hookWorkspaceFolder?.uri; + hooks = parseSubagentHooksFromYaml(hooksRaw, workspaceRootUri, userHome, target); + } + + return { uri, name, description, model, tools, handOffs, argumentHint, target, visibility, agents, hooks, agentInstructions, source }; }) ); 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 e5fd0a7a691a0..c5931a7d73213 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -23,7 +23,8 @@ import { ILanguageModelsService } from '../../languageModels.js'; import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; import { IChatAgentRequest, IChatAgentService } from '../../participants/chatAgents.js'; import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js'; -import { ChatRequestHooks } from '../../promptSyntax/hookSchema.js'; +import { ChatRequestHooks, mergeHooks } from '../../promptSyntax/hookSchema.js'; +import { HookType } from '../../promptSyntax/hookTypes.js'; import { ICustomAgent, IPromptsService } from '../../promptSyntax/service/promptsService.js'; import { isBuiltinAgent } from '../../promptSyntax/utils/promptsServiceUtils.js'; import { @@ -260,6 +261,20 @@ export class RunSubagentTool extends Disposable implements IToolImpl { this.logService.warn('[ChatService] Failed to collect hooks:', error); } + // Merge subagent-level hooks (from the agent's frontmatter) with global hooks. + // Remap Stop hooks to SubagentStop since the agent is running as a subagent. + if (subagent?.hooks) { + const remapped: ChatRequestHooks = { ...subagent.hooks }; + if (remapped[HookType.Stop]) { + const stopHooks = remapped[HookType.Stop]; + (remapped as Record)[HookType.SubagentStop] = remapped[HookType.SubagentStop] + ? [...remapped[HookType.SubagentStop], ...stopHooks] + : stopHooks; + (remapped as Record)[HookType.Stop] = undefined; + } + collectedHooks = mergeHooks(collectedHooks, remapped); + } + // Build the agent request const agentRequest: IChatAgentRequest = { sessionResource: invocation.context.sessionResource, diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts index d75ce8adbb8a9..33cbbd85c1aae 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { findHookCommandSelection } from '../../../browser/promptSyntax/hookUtils.js'; +import { findHookCommandInYaml, findHookCommandSelection } from '../../../browser/promptSyntax/hookUtils.js'; import { ITextEditorSelection } from '../../../../../../platform/editor/common/editor.js'; import { buildNewHookEntry, HookSourceFormat } from '../../../common/promptSyntax/hookCompatibility.js'; @@ -722,4 +722,232 @@ suite('hookUtils', () => { }); }); }); + + suite('findHookCommandInYaml', () => { + + test('finds unquoted command value', () => { + const content = [ + '---', + 'hooks:', + ' sessionStart:', + ' - command: echo hello', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'echo hello'); + assert.deepStrictEqual(result, { + startLineNumber: 4, + startColumn: 16, + endLineNumber: 4, + endColumn: 26 + }); + }); + + test('finds double-quoted command value', () => { + const content = [ + '---', + 'hooks:', + ' sessionStart:', + ' - command: "echo hello"', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'echo hello'); + }); + + test('finds single-quoted command value', () => { + const content = [ + '---', + 'hooks:', + ' sessionStart:', + ` - command: 'echo hello'`, + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'echo hello'); + }); + + test('finds command without list prefix', () => { + const content = [ + '---', + 'hooks:', + ' sessionStart:', + ' command: run-lint', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'run-lint'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'run-lint'); + }); + + test('does not match substring of a longer command', () => { + const content = [ + '---', + 'hooks:', + ' sessionStart:', + ' - command: echo hello-world', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when command is not found', () => { + const content = [ + '---', + 'hooks:', + ' sessionStart:', + ' - command: echo hello', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo goodbye'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when no command lines exist', () => { + const content = [ + '---', + 'name: my-agent', + 'description: An agent', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined for empty content', () => { + const result = findHookCommandInYaml('', 'echo hello'); + assert.strictEqual(result, undefined); + }); + + test('finds first matching command when multiple exist', () => { + const content = [ + '---', + 'hooks:', + ' sessionStart:', + ' - command: echo hello', + ' userPromptSubmit:', + ' - command: echo hello', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.ok(result); + assert.strictEqual(result.startLineNumber, 4); + }); + + test('ignores lines that are not command fields', () => { + const content = [ + '---', + 'description: run command echo hello', + 'hooks:', + ' sessionStart:', + ' - command: echo hello', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.ok(result); + assert.strictEqual(result.startLineNumber, 5); + }); + + test('handles command with special characters', () => { + const content = [ + '---', + 'hooks:', + ' preToolUse:', + ' - command: echo "foo" > /tmp/out.txt', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo "foo" > /tmp/out.txt'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'echo "foo" > /tmp/out.txt'); + }); + + test('matches command followed by trailing whitespace', () => { + const content = [ + '---', + 'hooks:', + ' sessionStart:', + ' - command: echo hello ', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'echo hello'); + }); + + test('finds short command that is a substring of the key name', () => { + const content = [ + 'hooks:', + ' Stop:', + ' - timeout: 10', + ' command: "a"', + ' type: command', + ].join('\n'); + const result = findHookCommandInYaml(content, 'a'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'a'); + assert.strictEqual(result.startLineNumber, 4); + }); + + test('finds short command in bash field that is a substring of the key name', () => { + const content = [ + 'hooks:', + ' sessionStart:', + ' - bash: "a"', + ' type: command', + ].join('\n'); + const result = findHookCommandInYaml(content, 'a'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'a'); + assert.strictEqual(result.startLineNumber, 3); + }); + + test('finds command in powershell field', () => { + const content = [ + 'hooks:', + ' sessionStart:', + ' - powershell: "echo hello"', + ' type: command', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'echo hello'); + assert.strictEqual(result.startLineNumber, 3); + }); + + test('finds command in windows field', () => { + const content = [ + 'hooks:', + ' sessionStart:', + ' - windows: "dir"', + ' type: command', + ].join('\n'); + const result = findHookCommandInYaml(content, 'dir'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'dir'); + assert.strictEqual(result.startLineNumber, 3); + }); + + test('finds command in linux and osx fields', () => { + const content = [ + 'hooks:', + ' sessionStart:', + ' - linux: "ls"', + ' osx: "ls -G"', + ' type: command', + ].join('\n'); + const linuxResult = findHookCommandInYaml(content, 'ls'); + assert.ok(linuxResult); + assert.strictEqual(getSelectedText(content, linuxResult), 'ls'); + assert.strictEqual(linuxResult.startLineNumber, 3); + + const osxResult = findHookCommandInYaml(content, 'ls -G'); + assert.ok(osxResult); + assert.strictEqual(getSelectedText(content, osxResult), 'ls -G'); + assert.strictEqual(osxResult.startLineNumber, 4); + }); + }); }); 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 1daf164ddcdd1..4ba131c89506a 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 @@ -143,6 +143,7 @@ suite('PromptHeaderAutocompletion', () => { { label: 'disable-model-invocation', result: 'disable-model-invocation: ${0:true}' }, { label: 'github', result: 'github: $0' }, { label: 'handoffs', result: 'handoffs: $0' }, + { label: 'hooks', result: 'hooks:\n ${1|SessionStart,SessionEnd,UserPromptSubmit,PreToolUse,PostToolUse,PreCompact,SubagentStart,SubagentStop,Stop,ErrorOccurred|}:\n - type: command\n command: "$2"' }, { label: 'model', result: 'model: ${0:MAE 4 (olama)}' }, { label: 'name', result: 'name: $0' }, { label: 'target', result: 'target: ${0:vscode}' }, @@ -390,6 +391,249 @@ suite('PromptHeaderAutocompletion', () => { const labels = actual.map(a => a.label); assert.ok(!labels.includes('BG Agent Model (copilot)'), 'Models with targetChatSessionType should be excluded from agent model array completions'); }); + + test('complete hooks value with New Hook snippet', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks: |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + assert.deepStrictEqual(actual, [ + { + label: 'New Hook', + result: 'hooks: \n ${1|SessionStart,SessionEnd,UserPromptSubmit,PreToolUse,PostToolUse,PreCompact,SubagentStart,SubagentStop,Stop,ErrorOccurred|}:\n - type: command\n command: "$2"' + }, + ]); + }); + + test('complete hooks value with New Hook snippet for vscode target', async () => { + const content = [ + '---', + 'description: "Test"', + 'target: vscode', + 'hooks: |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + assert.deepStrictEqual(actual, [ + { + label: 'New Hook', + result: 'hooks: \n ${1|SessionStart,UserPromptSubmit,PreToolUse,PostToolUse,PreCompact,SubagentStart,SubagentStop,Stop|}:\n - type: command\n command: "$2"' + }, + ]); + }); + + test('complete hook event names inside hooks map', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: "echo hi"', + ' |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label).sort(); + // SessionStart should be excluded since it already exists + assert.ok(!labels.includes('SessionStart'), 'SessionStart should not be suggested when already present'); + assert.ok(labels.includes('SessionEnd'), 'SessionEnd should be suggested'); + assert.ok(labels.includes('PreToolUse'), 'PreToolUse should be suggested'); + assert.ok(labels.includes('Stop'), 'Stop should be suggested'); + }); + + test('complete hook event names for vscode target excludes existing hooks', async () => { + const content = [ + '---', + 'description: "Test"', + 'target: vscode', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: "echo hi"', + ' PreToolUse:', + ' - type: command', + ' command: "lint"', + ' |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label).sort(); + assert.ok(!labels.includes('SessionStart'), 'SessionStart should not be suggested when already present'); + assert.ok(!labels.includes('PreToolUse'), 'PreToolUse should not be suggested when already present'); + assert.ok(labels.includes('UserPromptSubmit'), 'UserPromptSubmit should be suggested'); + assert.ok(labels.includes('PostToolUse'), 'PostToolUse should be suggested'); + // SessionEnd is not available for vscode target + assert.ok(!labels.includes('SessionEnd'), 'SessionEnd should not be available for vscode target'); + }); + + test('complete hook event names on empty line before existing hooks', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' |', + ' SessionStart:', + ' - type: command', + ' command: "echo hi"', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label).sort(); + assert.ok(!labels.includes('SessionStart'), 'SessionStart should not be suggested when already present'); + assert.ok(labels.includes('SessionEnd'), 'SessionEnd should be suggested'); + assert.ok(labels.includes('PreToolUse'), 'PreToolUse should be suggested'); + }); + + test('complete hook event names while editing existing key name', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' S|:', + ' - type: command', + ' command: "echo hi"', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label).sort(); + assert.ok(labels.includes('SessionStart'), 'SessionStart should be suggested'); + assert.ok(labels.includes('SubagentStart'), 'SubagentStart should be suggested'); + assert.ok(labels.includes('Stop'), 'Stop should be suggested'); + // Verify insertText only replaces the key (no full snippet) + const sessionStartItem = actual.find(a => a.label === 'SessionStart'); + assert.ok(sessionStartItem); + assert.strictEqual(sessionStartItem.result, ' SessionStart:'); + }); + + test('hooks: cursor right after colon triggers New Hook snippet', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks: |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label); + assert.ok(labels.includes('New Hook'), 'New Hook snippet should be suggested'); + }); + + test('hooks: typing event name on next line triggers hook events', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' S|', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label); + assert.ok(labels.includes('SessionStart'), 'SessionStart should be suggested'); + assert.ok(labels.includes('SessionEnd'), 'SessionEnd should be suggested'); + assert.ok(labels.includes('Stop'), 'Stop should be suggested'); + }); + + test('typing field name in first command entry triggers command fields', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionEnd:', + ' - t|', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label); + assert.ok(labels.includes('type'), 'type should be suggested'); + assert.ok(labels.includes('command'), 'command should be suggested'); + assert.ok(labels.includes('timeout'), 'timeout should be suggested'); + }); + + test('typing field name after existing field triggers remaining command fields', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionEnd:', + ' - type: command', + ' c|', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label); + assert.ok(labels.includes('command'), 'command should be suggested'); + assert.ok(labels.includes('cwd'), 'cwd should be suggested'); + assert.ok(!labels.includes('type'), 'type should not be suggested when already present'); + }); + + test('typing event name after existing hook triggers hook events', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionEnd:', + ' - type: command', + ' command: echo "Session ended."', + ' U|', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label); + assert.ok(labels.includes('UserPromptSubmit'), 'UserPromptSubmit should be suggested'); + assert.ok(!labels.includes('SessionEnd'), 'SessionEnd should not be suggested when already present'); + }); + + test('typing event name between existing hooks triggers hook events', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionEnd:', + ' - type: command', + ' command: echo "Session ended."', + ' S|', + ' UserPromptSubmit:', + ' - type: command', + ' command: echo "User submitted."', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label); + assert.ok(labels.includes('SessionStart'), 'SessionStart should be suggested'); + assert.ok(labels.includes('Stop'), 'Stop should be suggested'); + assert.ok(!labels.includes('SessionEnd'), 'SessionEnd should not be suggested when already present'); + assert.ok(!labels.includes('UserPromptSubmit'), 'UserPromptSubmit should not be suggested when already present'); + }); + + test('cursor after hook event colon triggers New Command snippet', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionEnd: |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label); + assert.ok(labels.includes('New Command'), 'New Command snippet should be suggested'); + assert.strictEqual(actual.length, 1, 'Only one suggestion should be returned'); + }); }); suite('claude agent header completions', () => { 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 cd985b7ba74f8..e1ad97bb79165 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 @@ -551,7 +551,7 @@ suite('PromptValidator', () => { assert.deepStrictEqual( markers.map(m => ({ severity: m.severity, message: m.message })), [ - { severity: MarkerSeverity.Warning, message: `Attribute 'applyTo' is not supported in VS Code agent files. Supported: agents, argument-hint, description, disable-model-invocation, github, handoffs, model, name, target, tools, user-invocable.` }, + { severity: MarkerSeverity.Warning, message: `Attribute 'applyTo' is not supported in VS Code agent files. Supported: agents, argument-hint, description, disable-model-invocation, github, handoffs, hooks, model, name, target, tools, user-invocable.` }, ] ); }); @@ -1416,6 +1416,358 @@ suite('PromptValidator', () => { assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be 'true' or 'false'.`); } }); + + test('hooks - valid hook commands', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: echo hello', + ' PreToolUse:', + ' - type: command', + ' command: ./validate.sh', + ' cwd: scripts', + ' timeout: 30', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual(markers, []); + }); + + test('hooks - must be a map', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks: invalid', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'hooks' attribute must be a map of hook event types to command arrays.` }, + ] + ); + }); + + test('hooks - unknown hook event type', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' UnknownEvent:', + ' - type: command', + ' command: echo hello', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Warning, message: `Unknown hook event type 'UnknownEvent'. Supported: SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, SubagentStart, SubagentStop, Stop, ErrorOccurred.` }, + ] + ); + }); + + test('hooks - hook value must be array', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart: invalid', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `Hook event 'SessionStart' must have an array of command objects as its value.` }, + ] + ); + }); + + test('hooks - command item must be object', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - just a string', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `Each hook command must be an object.` }, + ] + ); + }); + + test('hooks - missing type property', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - command: echo hello', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `Hook command is missing required property 'type'.` }, + ] + ); + }); + + test('hooks - type must be command', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: script', + ' command: echo hello', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'type' property in a hook command must be 'command'.` }, + ] + ); + }); + + test('hooks - missing command field', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `Hook command must specify at least one of 'command', 'windows', 'linux', or 'osx'.` }, + ] + ); + }); + + test('hooks - empty command string', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: ""', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'command' property in a hook command must be a non-empty string.` }, + ] + ); + }); + + test('hooks - platform-specific commands are valid', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' windows: echo hello', + ' linux: echo hello', + ' osx: echo hello', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual(markers, []); + }); + + test('hooks - env must be a map with string values', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: echo hello', + ' env: invalid', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'env' property in a hook command must be a map of string values.` }, + ] + ); + }); + + test('hooks - valid env map', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: echo hello', + ' env:', + ' NODE_ENV: production', + ' DEBUG: "true"', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual(markers, []); + }); + + test('hooks - unknown property warns', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: echo hello', + ' unknownProp: value', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Warning, message: `Unknown property 'unknownProp' in hook command.` }, + ] + ); + }); + + test('hooks - timeout must be number', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: echo hello', + ' timeout: not-a-number', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'timeout' property in a hook command must be a number.` }, + ] + ); + }); + + test('hooks - cwd must be string', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: echo hello', + ' cwd:', + ' - array', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'cwd' property in a hook command must be a string.` }, + ] + ); + }); + + test('hooks - multiple errors in one command', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: script', + ' unknownProp: value', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'type' property in a hook command must be 'command'.` }, + { severity: MarkerSeverity.Warning, message: `Unknown property 'unknownProp' in hook command.` }, + { severity: MarkerSeverity.Error, message: `Hook command must specify at least one of 'command', 'windows', 'linux', or 'osx'.` }, + ] + ); + }); + + test('hooks - nested matcher format is valid', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' UserPromptSubmit:', + ' - hooks:', + ' - type: command', + ' command: "echo foo"', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual(markers, []); + }); + + test('hooks - nested matcher validates inner commands', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' PreToolUse:', + ' - matcher: Bash', + ' hooks:', + ' - type: script', + ' command: "echo foo"', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'type' property in a hook command must be 'command'.` }, + ] + ); + }); + + test('hooks - nested hooks must be array', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' PreToolUse:', + ' - hooks: invalid', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'hooks' property in a matcher must be an array of command objects.` }, + ] + ); + }); }); suite('instructions', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts index 56cf17fafc88f..ee30e3f60dc08 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts @@ -5,9 +5,11 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { resolveHookCommand, resolveEffectiveCommand, formatHookCommandLabel, IHookCommand } from '../../../common/promptSyntax/hookSchema.js'; +import { resolveHookCommand, resolveEffectiveCommand, formatHookCommandLabel, IHookCommand, parseSubagentHooksFromYaml } from '../../../common/promptSyntax/hookSchema.js'; import { URI } from '../../../../../../base/common/uri.js'; import { OperatingSystem } from '../../../../../../base/common/platform.js'; +import { HookType } from '../../../common/promptSyntax/hookTypes.js'; +import { Range } from '../../../../../../editor/common/core/range.js'; suite('HookSchema', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -485,4 +487,162 @@ suite('HookSchema', () => { assert.strictEqual(formatHookCommandLabel(hook, OperatingSystem.Windows), 'default-command'); }); }); + + suite('parseSubagentHooksFromYaml', () => { + + const workspaceRoot = URI.file('/workspace'); + const userHome = '/home/user'; + + const dummyRange = new Range(1, 1, 1, 1); + + function makeScalar(value: string): import('../../../common/promptSyntax/promptFileParser.js').IScalarValue { + return { type: 'scalar', value, range: dummyRange, format: 'none' }; + } + + function makeMap(entries: Record): import('../../../common/promptSyntax/promptFileParser.js').IMapValue { + const properties = Object.entries(entries).map(([key, value]) => ({ + key: makeScalar(key), + value, + })); + return { type: 'map', properties, range: dummyRange }; + } + + function makeSequence(items: import('../../../common/promptSyntax/promptFileParser.js').IValue[]): import('../../../common/promptSyntax/promptFileParser.js').ISequenceValue { + return { type: 'sequence', items, range: dummyRange }; + } + + test('parses direct command format (without matcher)', () => { + // hooks: + // PreToolUse: + // - type: command + // command: "./scripts/validate.sh" + const hooksMap = makeMap({ + 'PreToolUse': makeSequence([ + makeMap({ + 'type': makeScalar('command'), + 'command': makeScalar('./scripts/validate.sh'), + }), + ]), + }); + + const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome); + + assert.strictEqual(result[HookType.PreToolUse]?.length, 1); + assert.strictEqual(result[HookType.PreToolUse]![0].command, './scripts/validate.sh'); + }); + + test('parses Claude format (with matcher)', () => { + // hooks: + // PreToolUse: + // - matcher: "Bash" + // hooks: + // - type: command + // command: "./scripts/validate-readonly.sh" + const hooksMap = makeMap({ + 'PreToolUse': makeSequence([ + makeMap({ + 'matcher': makeScalar('Bash'), + 'hooks': makeSequence([ + makeMap({ + 'type': makeScalar('command'), + 'command': makeScalar('./scripts/validate-readonly.sh'), + }), + ]), + }), + ]), + }); + + const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome); + + assert.strictEqual(result[HookType.PreToolUse]?.length, 1); + assert.strictEqual(result[HookType.PreToolUse]![0].command, './scripts/validate-readonly.sh'); + }); + + test('parses multiple hook types', () => { + const hooksMap = makeMap({ + 'PreToolUse': makeSequence([ + makeMap({ + 'type': makeScalar('command'), + 'command': makeScalar('./scripts/pre.sh'), + }), + ]), + 'PostToolUse': makeSequence([ + makeMap({ + 'matcher': makeScalar('Edit|Write'), + 'hooks': makeSequence([ + makeMap({ + 'type': makeScalar('command'), + 'command': makeScalar('./scripts/lint.sh'), + }), + ]), + }), + ]), + }); + + const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome); + + assert.strictEqual(result[HookType.PreToolUse]?.length, 1); + assert.strictEqual(result[HookType.PreToolUse]![0].command, './scripts/pre.sh'); + assert.strictEqual(result[HookType.PostToolUse]?.length, 1); + assert.strictEqual(result[HookType.PostToolUse]![0].command, './scripts/lint.sh'); + }); + + test('skips unknown hook types', () => { + const hooksMap = makeMap({ + 'UnknownHook': makeSequence([ + makeMap({ + 'type': makeScalar('command'), + 'command': makeScalar('echo "ignored"'), + }), + ]), + }); + + const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome); + + assert.strictEqual(result[HookType.PreToolUse], undefined); + assert.strictEqual(result[HookType.PostToolUse], undefined); + }); + + test('handles command without type field', () => { + const hooksMap = makeMap({ + 'PreToolUse': makeSequence([ + makeMap({ + 'command': makeScalar('./scripts/validate.sh'), + }), + ]), + }); + + const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome); + + assert.strictEqual(result[HookType.PreToolUse]?.length, 1); + assert.strictEqual(result[HookType.PreToolUse]![0].command, './scripts/validate.sh'); + }); + + test('resolves cwd relative to workspace', () => { + const hooksMap = makeMap({ + 'SessionStart': makeSequence([ + makeMap({ + 'type': makeScalar('command'), + 'command': makeScalar('echo "start"'), + 'cwd': makeScalar('src'), + }), + ]), + }); + + const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome); + + assert.strictEqual(result[HookType.SessionStart]?.length, 1); + assert.deepStrictEqual(result[HookType.SessionStart]![0].cwd, URI.file('/workspace/src')); + }); + + test('skips non-sequence hook values', () => { + const hooksMap = makeMap({ + 'PreToolUse': makeScalar('not-a-sequence'), + }); + + const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome); + + assert.strictEqual(result[HookType.PreToolUse], undefined); + }); + }); }); 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 7d51bb6782f55..a5e0efdd72b44 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 @@ -794,6 +794,7 @@ suite('PromptsService', () => { target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local } }, @@ -850,6 +851,7 @@ suite('PromptsService', () => { target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local }, }, @@ -925,6 +927,7 @@ suite('PromptsService', () => { target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local } }, @@ -943,6 +946,7 @@ suite('PromptsService', () => { target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent2.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1013,6 +1017,7 @@ suite('PromptsService', () => { argumentHint: undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/github-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1031,6 +1036,7 @@ suite('PromptsService', () => { tools: undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/vscode-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1049,6 +1055,7 @@ suite('PromptsService', () => { target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/generic-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1126,6 +1133,7 @@ suite('PromptsService', () => { argumentHint: undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/copilot-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1146,6 +1154,7 @@ suite('PromptsService', () => { argumentHint: undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.claude/agents/claude-agent.md'), source: { storage: PromptsStorage.local } }, @@ -1165,6 +1174,7 @@ suite('PromptsService', () => { argumentHint: undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.claude/agents/claude-agent2.md'), source: { storage: PromptsStorage.local } }, @@ -1221,6 +1231,7 @@ suite('PromptsService', () => { target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/demonstrate.md'), source: { storage: PromptsStorage.local } } @@ -1291,6 +1302,7 @@ suite('PromptsService', () => { argumentHint: undefined, target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/restricted-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1309,6 +1321,7 @@ suite('PromptsService', () => { tools: undefined, target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/no-access-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1327,6 +1340,7 @@ suite('PromptsService', () => { tools: undefined, target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/full-access-agent.agent.md'), source: { storage: PromptsStorage.local } },