diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index c84d58560d1ed0..15e5aa562304e9 100644 --- a/src/vs/platform/agentPlugins/common/pluginParsers.ts +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -787,6 +787,8 @@ const HOOK_TYPE_MAP: Record = { 'userPromptTransformed': 'UserPromptTransformed', 'preToolUse': 'PreToolUse', 'postToolUse': 'PostToolUse', + 'preCompact': 'PreCompact', + 'subagentStart': 'SubagentStart', 'agentStop': 'Stop', 'subagentStop': 'SubagentStop', 'errorOccurred': 'ErrorOccurred', diff --git a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts index e9a8a0ee5f4ee6..3590880e836cde 100644 --- a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts +++ b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts @@ -929,10 +929,20 @@ suite('pluginParsers', () => { const groups = parse({ hooks: { postToolUse: [{ hooks: [{ type: 'command', command: 'echo a' }] }], + preCompact: [{ type: 'command', command: 'echo compact' }], + subagentStart: [{ type: 'command', command: 'echo subagent' }], bogusEvent: [{ hooks: [{ type: 'command', command: 'echo b' }] }], }, }); - assert.deepStrictEqual(groups.map(g => g.type), ['PostToolUse']); + assert.deepStrictEqual(groups.map(g => ({ + type: g.type, + originalId: g.originalId, + commands: g.commands.map(c => c.command), + })), [ + { type: 'PostToolUse', originalId: 'postToolUse', commands: ['echo a'] }, + { type: 'PreCompact', originalId: 'preCompact', commands: ['echo compact'] }, + { type: 'SubagentStart', originalId: 'subagentStart', commands: ['echo subagent'] }, + ]); }); test('recognizes a userPromptTransformed-only hook file', () => { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index ac2aab6929e9b1..becef60af45445 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -24,7 +24,7 @@ import { IFileService } from '../../../../../platform/files/common/files.js'; import { HOOK_METADATA, HOOKS_BY_TARGET, HookType, IHookTypeMeta } from '../../common/promptSyntax/hookTypes.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 { getHookSourceFormat, HookSourceFormat, buildNewHookEntry, buildNewHookFile } 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'; @@ -37,7 +37,7 @@ import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browse import { Range } from '../../../../../editor/common/core/range.js'; import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; -import { OperatingSystem, OS } from '../../../../../base/common/platform.js'; +import { OS } from '../../../../../base/common/platform.js'; /** * Action ID for the `Configure Hooks` action. @@ -100,7 +100,7 @@ async function addHookToFile( openEditorOverride?: (resource: URI, options?: { selection?: ITextEditorSelection }) => Promise, ): Promise { // Parse existing file - let hooksContent: { hooks: Record }; + let hooksContent: { version?: number; hooks: Record }; const fileExists = await fileService.exists(hookFileUri); if (fileExists) { @@ -126,8 +126,7 @@ async function addHookToFile( const sourceFormat = getHookSourceFormat(hookFileUri); const isClaude = sourceFormat === HookSourceFormat.Claude; - // Detect naming convention from existing keys - const useCopilotCliNamingConvention = !isClaude && usesCopilotCliNaming(hooksContent.hooks); + const useCopilotCliNamingConvention = !isClaude && (!fileExists || typeof hooksContent.version === 'number' || usesCopilotCliNaming(hooksContent.hooks)); const hookTypeKeyName = isClaude ? (getClaudeHookTypeName(hookTypeId) ?? hookTypeId) : getHookTypeKeyName(hookTypeId, useCopilotCliNamingConvention); @@ -164,7 +163,7 @@ async function addHookToFile( jsonContent = applyEdits(originalText, edits); } else { // New file - use JSON.stringify since there are no comments to preserve - const newContent = { hooks: { [keyToUse]: [newHookEntry] } }; + const newContent = buildNewHookFile(sourceFormat, hookTypeId); jsonContent = JSON.stringify(newContent, null, '\t'); } @@ -787,31 +786,8 @@ export async function showConfigureHooksQuickPick( // Detect if new file is a Claude hooks file based on its path const newFileFormat = getHookSourceFormat(hookFileUri); - const isClaudeNewFile = newFileFormat === HookSourceFormat.Claude; - const isCopilotCliOnly = !isClaudeNewFile - && !new Set(Object.values(HOOKS_BY_TARGET[Target.VSCode])).has(selectedHookType!.hookType) - && new Set(Object.values(HOOKS_BY_TARGET[Target.GitHubCopilot])).has(selectedHookType!.hookType); - const hookTypeKey = isClaudeNewFile - ? (getClaudeHookTypeName(selectedHookType!.hookType) ?? selectedHookType!.hookType) - : isCopilotCliOnly - ? (getCopilotCliHookTypeName(selectedHookType!.hookType) ?? selectedHookType!.hookType) - : selectedHookType!.hookType; - const newFileHookEntry = isCopilotCliOnly - ? { type: 'command', [targetOS === OperatingSystem.Windows ? 'powershell' : 'bash']: '' } - : buildNewHookEntry(newFileFormat); - const commandFieldKey = isCopilotCliOnly - ? (targetOS === OperatingSystem.Windows ? 'powershell' : 'bash') - : 'command'; - - // Create new hook file with the selected hook type - const hooksContent: Record = { - ...(isCopilotCliOnly ? { version: 1 } : {}), - hooks: { - [hookTypeKey]: [ - newFileHookEntry - ] - } - }; + const hooksContent = buildNewHookFile(newFileFormat, selectedHookType!.hookType); + const hookTypeKey = Object.keys(hooksContent.hooks)[0]; const jsonContent = JSON.stringify(hooksContent, null, '\t'); await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); @@ -819,7 +795,7 @@ export async function showConfigureHooksQuickPick( options?.onHookFileCreated?.(hookFileUri); // Find the selection for the new hook's command field - const selection = findHookCommandSelection(jsonContent, hookTypeKey, 0, commandFieldKey); + const selection = findHookCommandSelection(jsonContent, hookTypeKey, 0, 'command'); // Open editor with selection if (options?.openEditor) { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts index 64e46956b17ae5..26c914d30f2a6b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts @@ -6,8 +6,8 @@ import { URI } from '../../../../../base/common/uri.js'; import { basename, dirname } from '../../../../../base/common/path.js'; import { IHookCommand, toHookType } from './hookSchema.js'; -import { parseClaudeHooks, extractHookCommandsFromItem } from './hookClaudeCompat.js'; -import { resolveCopilotCliHookType } from './hookCopilotCliCompat.js'; +import { parseClaudeHooks, extractHookCommandsFromItem, getClaudeHookTypeName } from './hookClaudeCompat.js'; +import { resolveCopilotCliHookType, getCopilotCliHookTypeName } from './hookCopilotCliCompat.js'; import { HookType } from './hookTypes.js'; /** @@ -212,3 +212,18 @@ export function buildNewHookEntry(format: HookSourceFormat): Record[]> } { + const hookTypeName = format === HookSourceFormat.Claude + ? (getClaudeHookTypeName(hookType) ?? hookType) + : (getCopilotCliHookTypeName(hookType) ?? hookType); + return { + ...(format === HookSourceFormat.Copilot ? { version: 1 } : {}), + hooks: { + [hookTypeName]: [buildNewHookEntry(format)] + } + }; +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts index b893206c6e37d2..13982e3e6600c7 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts @@ -283,6 +283,7 @@ export const hookFileSchema: IJSONSchema = { // VS Code / PascalCase format properties: { hooks: { + deprecationMessage: nls.localize('hookFile.localFormatDeprecated', 'The Local hook format is deprecated. Use the Copilot CLI format with "version": 1, camelCase event names, and "timeoutSec" instead of "timeout". Migration may require script changes: CLI hooks use different JSON input/output shapes (for example, "toolName" instead of "tool_name" and top-level "permissionDecision" instead of "hookSpecificOutput.permissionDecision"). Renaming configuration fields alone may change behavior.'), properties: vscodeHookProperties } } @@ -292,18 +293,19 @@ export const hookFileSchema: IJSONSchema = { label: nls.localize('hookFile.snippet.basic', 'Basic hook configuration'), description: nls.localize('hookFile.snippet.basic.description', 'A basic hook configuration with common hooks'), body: { + version: 1, hooks: { - SessionStart: [ + sessionStart: [ { type: 'command', command: '${1:echo "Session started" >> session.log}', } ], - PreToolUse: [ + preToolUse: [ { type: 'command', command: '${2:./scripts/validate.sh}', - timeout: 15 + timeoutSec: 15 } ] } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookTypes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookTypes.ts index e4283de18cd29f..96c3490e50baf2 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookTypes.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookTypes.ts @@ -48,6 +48,8 @@ export const HOOKS_BY_TARGET: Record> = { 'userPromptTransformed': HookType.UserPromptTransformed, 'preToolUse': HookType.PreToolUse, 'postToolUse': HookType.PostToolUse, + 'preCompact': HookType.PreCompact, + 'subagentStart': HookType.SubagentStart, 'agentStop': HookType.Stop, 'subagentStop': HookType.SubagentStop, 'errorOccurred': HookType.ErrorOccurred, diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts index 40ddf345a63bb9..8a013418bcdc1d 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts @@ -6,13 +6,58 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { getCanonicalAgentHookName, HOOKS_BY_TARGET, HookType } from '../../../common/promptSyntax/hookTypes.js'; -import { parseCopilotHooks, parseHooksFromFile, HookSourceFormat } from '../../../common/promptSyntax/hookCompatibility.js'; +import { parseCopilotHooks, parseHooksFromFile, HookSourceFormat, buildNewHookFile } from '../../../common/promptSyntax/hookCompatibility.js'; import { Target } from '../../../common/promptSyntax/promptTypes.js'; import { URI } from '../../../../../../base/common/uri.js'; suite('HookCompatibility', () => { ensureNoDisposablesAreLeakedInTestSuite(); + suite('buildNewHookFile', () => { + const copilotHookNames: [HookType, string][] = [ + [HookType.SessionStart, 'sessionStart'], + [HookType.SessionEnd, 'sessionEnd'], + [HookType.UserPromptSubmit, 'userPromptSubmitted'], + [HookType.UserPromptTransformed, 'userPromptTransformed'], + [HookType.PreToolUse, 'preToolUse'], + [HookType.PostToolUse, 'postToolUse'], + [HookType.PreCompact, 'preCompact'], + [HookType.SubagentStart, 'subagentStart'], + [HookType.SubagentStop, 'subagentStop'], + [HookType.Stop, 'agentStop'], + [HookType.ErrorOccurred, 'errorOccurred'], + ]; + + for (const [hookType, eventName] of copilotHookNames) { + test(`creates a versioned Copilot file for ${hookType}`, () => { + assert.deepStrictEqual(buildNewHookFile(HookSourceFormat.Copilot, hookType), { + version: 1, + hooks: { + [eventName]: [{ type: 'command', command: '' }] + } + }); + }); + } + + test('generated Copilot events remain discoverable', () => { + assert.deepStrictEqual(copilotHookNames.map(([hookType]) => { + const file = buildNewHookFile(HookSourceFormat.Copilot, hookType); + return [...parseCopilotHooks(file, undefined, '').keys()]; + }), copilotHookNames.map(([hookType]) => [hookType])); + }); + + test('preserves the Claude file format', () => { + assert.deepStrictEqual(buildNewHookFile(HookSourceFormat.Claude, HookType.PreToolUse), { + hooks: { + PreToolUse: [{ + matcher: '', + hooks: [{ type: 'command', command: '' }] + }] + } + }); + }); + }); + test('recognizes userPromptTransformed for Copilot without advertising it for other harnesses', () => { assert.deepStrictEqual({ copilot: HOOKS_BY_TARGET[Target.GitHubCopilot].userPromptTransformed, 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 daf50adaf05526..e2b51dc043d716 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 @@ -33,6 +33,35 @@ suite('HookSchema', () => { }); }); + test('only the Local hook format is deprecated', () => { + assert.deepStrictEqual({ + shared: hookFileSchema.properties?.hooks.deprecationMessage, + local: hookFileSchema.else?.properties?.hooks.deprecationMessage, + cli: hookFileSchema.then?.properties?.hooks.deprecationMessage, + }, { + shared: undefined, + local: 'The Local hook format is deprecated. Use the Copilot CLI format with "version": 1, camelCase event names, and "timeoutSec" instead of "timeout". Migration may require script changes: CLI hooks use different JSON input/output shapes (for example, "toolName" instead of "tool_name" and top-level "permissionDecision" instead of "hookSpecificOutput.permissionDecision"). Renaming configuration fields alone may change behavior.', + cli: undefined, + }); + }); + + test('default hook snippet uses the Copilot CLI format', () => { + assert.deepStrictEqual(hookFileSchema.defaultSnippets?.[0].body, { + version: 1, + hooks: { + sessionStart: [{ + type: 'command', + command: '${1:echo "Session started" >> session.log}', + }], + preToolUse: [{ + type: 'command', + command: '${2:./scripts/validate.sh}', + timeoutSec: 15, + }], + }, + }); + }); + suite('resolveHookCommand', () => { const workspaceRoot = URI.file('/workspace'); const userHome = '/home/user';