Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/vs/platform/agentPlugins/common/pluginParsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,8 @@ const HOOK_TYPE_MAP: Record<string, string> = {
'userPromptTransformed': 'UserPromptTransformed',
'preToolUse': 'PreToolUse',
'postToolUse': 'PostToolUse',
'preCompact': 'PreCompact',
'subagentStart': 'SubagentStart',
'agentStop': 'Stop',
'subagentStop': 'SubagentStop',
'errorOccurred': 'ErrorOccurred',
Expand Down
12 changes: 11 additions & 1 deletion src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
40 changes: 8 additions & 32 deletions src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -100,7 +100,7 @@ async function addHookToFile(
openEditorOverride?: (resource: URI, options?: { selection?: ITextEditorSelection }) => Promise<void>,
): Promise<void> {
// Parse existing file
let hooksContent: { hooks: Record<string, unknown[]> };
let hooksContent: { version?: number; hooks: Record<string, unknown[]> };
const fileExists = await fileService.exists(hookFileUri);

if (fileExists) {
Expand All @@ -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);
Expand Down Expand Up @@ -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');
}

Expand Down Expand Up @@ -787,39 +786,16 @@ 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<string, unknown> = {
...(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));

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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -212,3 +212,18 @@ export function buildNewHookEntry(format: HookSourceFormat): Record<string, unkn
}
return commandEntry;
}

/**
* Builds a new hook file, using the versioned CLI format for Copilot hooks.
*/
export function buildNewHookFile(format: HookSourceFormat, hookType: HookType): { version?: number; hooks: Record<string, Record<string, unknown>[]> } {
const hookTypeName = format === HookSourceFormat.Claude
? (getClaudeHookTypeName(hookType) ?? hookType)
: (getCopilotCliHookTypeName(hookType) ?? hookType);
return {
...(format === HookSourceFormat.Copilot ? { version: 1 } : {}),
hooks: {
[hookTypeName]: [buildNewHookEntry(format)]
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export const HOOKS_BY_TARGET: Record<Target, Record<string, HookType>> = {
'userPromptTransformed': HookType.UserPromptTransformed,
'preToolUse': HookType.PreToolUse,
'postToolUse': HookType.PostToolUse,
'preCompact': HookType.PreCompact,
'subagentStart': HookType.SubagentStart,
Comment thread
aeschli marked this conversation as resolved.
Resolved
'agentStop': HookType.Stop,
'subagentStop': HookType.SubagentStop,
'errorOccurred': HookType.ErrorOccurred,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading