diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index f4d0908c03fbe0..a9cf8c57fa883a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -42,7 +42,7 @@ import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDa import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; -import { AgentCustomization, CustomizationLoadStatus, CustomizationType, ResponsePartKind, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, isDefaultChatUri, parseChatUri, parseSubagentSessionUri, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ResponsePart, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { AgentCustomization, CustomizationLoadStatus, CustomizationType, ResponsePartKind, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, isDefaultChatUri, parseChatUri, parseSubagentSessionUri, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ResponsePart, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { ActiveClientState } from '../activeClientState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostCompletions } from '../agentHostCompletions.js'; @@ -2539,6 +2539,8 @@ function toDirectoryContentsType(type: DiscoveredType): ChildCustomizationType { case DiscoveredType.Instruction: case DiscoveredType.AgentInstruction: return CustomizationType.Rule; + case DiscoveredType.Hook: + return CustomizationType.Hook; } } @@ -2583,6 +2585,15 @@ async function toDiscoveredChildCustomization(file: URI, type: DiscoveredType, f }; return ruleCustomization; } + if (type === DiscoveredType.Hook) { + const hookCustomization: HookCustomization = { + type: CustomizationType.Hook, + id, + uri, + name: resourceBasename(file), + }; + return hookCustomization; + } // agent instruction return { type: CustomizationType.Rule, diff --git a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts index 9d7e78b2d4a1db..5d38d56678d7dd 100644 --- a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts +++ b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts @@ -29,6 +29,7 @@ export const enum DiscoveredType { Agent = 'agent', Skill = 'skill', Instruction = 'instruction', + Hook = 'hook', AgentInstruction = 'agentInstruction', } @@ -99,10 +100,12 @@ function compareDirectoryCustomization(a: DirectoryCustomization, b: DirectoryCu * Maximum recursion depth when traversing subdirectories for instruction files. */ const MAX_INSTRUCTIONS_RECURSION_DEPTH = 5; +const MAX_HOOKS_RECURSION_DEPTH = 8; const AGENT_FILE_SUFFIX = '.agent.md'; const MARKDOWN_SUFFIX = '.md'; const INSTRUCTION_FILE_SUFFIX = '.instructions.md'; +const HOOK_FILE_SUFFIX = '.json'; const SKILL_FILENAME = 'SKILL.md'; const README_FILENAME = 'README.md'; @@ -112,15 +115,16 @@ interface ISearchRoot { readonly recursive?: boolean; // whether to watch recursively for changes (defaults to false) } -interface IInstructionFile { +interface IFixedDiscoveryFile { readonly path: readonly string[]; readonly filenames: string[]; + readonly type: DiscoveredType; } /** * Builds the list of search roots for a given working directory and user home. - * Skills require a depth-2 scan (`/SKILL.md`); agents and instructions - * are flat single-directory scans. + * Skills require a depth-2 scan (`/SKILL.md`), agents are scanned at + * a single directory depth, and instructions/hooks are recursively scanned. */ const searchRoots: { workspace: ISearchRoot[]; user: ISearchRoot[] } = { workspace: [ @@ -131,11 +135,13 @@ const searchRoots: { workspace: ISearchRoot[]; user: ISearchRoot[] } = { { path: ['.agents', 'skills'], recursive: true, type: DiscoveredType.Skill }, { path: ['.claude', 'skills'], recursive: true, type: DiscoveredType.Skill }, { path: ['.github', 'instructions'], recursive: true, type: DiscoveredType.Instruction }, + { path: ['.github', 'hooks'], recursive: true, type: DiscoveredType.Hook }, ], user: [ { path: ['.copilot', 'agents'], type: DiscoveredType.Agent }, { path: ['.agents', 'skills'], recursive: true, type: DiscoveredType.Skill }, { path: ['.copilot', 'instructions'], recursive: true, type: DiscoveredType.Instruction }, + { path: ['.copilot', 'hooks'], recursive: true, type: DiscoveredType.Hook }, ], }; @@ -146,17 +152,22 @@ const searchRoots: { workspace: ISearchRoot[]; user: ISearchRoot[] } = { * Returns paths with filenames for workspace and user-home * locations */ -const agentInstructions: { workspace: IInstructionFile[]; user: IInstructionFile[] } = { +const fixedDiscoveryFiles: { workspace: IFixedDiscoveryFile[]; user: IFixedDiscoveryFile[] } = { workspace: [ - { path: ['.github'], filenames: ['copilot-instructions.md'] }, - { path: [], filenames: ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'] }, - { path: ['.claude'], filenames: ['CLAUDE.md'] }, + { path: ['.github'], filenames: ['copilot-instructions.md'], type: DiscoveredType.AgentInstruction }, + { path: [], filenames: ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'], type: DiscoveredType.AgentInstruction }, + { path: ['.claude'], filenames: ['CLAUDE.md'], type: DiscoveredType.AgentInstruction }, + { path: ['.github', 'copilot'], filenames: ['settings.json', 'settings.local.json'], type: DiscoveredType.Hook }, + { path: ['.claude'], filenames: ['settings.json', 'settings.local.json'], type: DiscoveredType.Hook }, ], user: [ - { path: ['.copilot'], filenames: ['copilot-instructions.md'] }, + { path: ['.copilot'], filenames: ['copilot-instructions.md'], type: DiscoveredType.AgentInstruction }, ], }; +// Back-compat alias for tests and callers that referenced the old symbol name. +const agentInstructions = fixedDiscoveryFiles; + function throwIfCancelled(token: CancellationToken): void { if (token.isCancellationRequested) { throw new CancellationError(); @@ -186,7 +197,7 @@ function addWatch(map: ResourceMap, watchUri: URI, recursive: boolea } /** - * Discovers customization files (agents, skills, and instructions) + * Discovers customization files (agents, skills, instructions, and hooks) * under well-known directories of the session's working directory and the * user's home, and emits {@link onDidChange} when any of those directories * change on disk. @@ -325,8 +336,8 @@ export class SessionCustomizationDiscovery extends Disposable { await Promise.all([ ...searchRoots.workspace.map(root => this._scanRoot(this._workingDirectory, root, seen, result, nextWatchRootUris, token)), ...searchRoots.user.map(root => this._scanRoot(this._userHome, root, seen, result, nextWatchRootUris, token)), - this._scanAgentInstructions(this._workingDirectory, agentInstructions.workspace, seen, result, nextWatchRootUris, token), - this._scanAgentInstructions(this._userHome, agentInstructions.user, seen, result, nextWatchRootUris, token) + this._scanFixedDiscoveryFiles(this._workingDirectory, fixedDiscoveryFiles.workspace, seen, result, nextWatchRootUris, token), + this._scanFixedDiscoveryFiles(this._userHome, fixedDiscoveryFiles.user, seen, result, nextWatchRootUris, token) ]); throwIfCancelled(token); @@ -405,10 +416,11 @@ export class SessionCustomizationDiscovery extends Disposable { } /** - * For agent instructions, create a single root for the base directory. + * For fixed discovery files (e.g. AGENTS.md, copilot-instructions.md, + * settings.json), create one discovered directory per type at the base. */ - private async _scanAgentInstructions(base: URI, roots: IInstructionFile[], seen: ResourceSet, result: IDiscoveredDirectory[], watchRootUris: ResourceMap, token: CancellationToken): Promise { - const files: IDiscoveredFile[] = []; + private async _scanFixedDiscoveryFiles(base: URI, roots: IFixedDiscoveryFile[], seen: ResourceSet, result: IDiscoveredDirectory[], watchRootUris: ResourceMap, token: CancellationToken): Promise { + const filesByType = new Map(); for (const root of roots) { throwIfCancelled(token); @@ -440,13 +452,19 @@ export class SessionCustomizationDiscovery extends Disposable { const uri = joinPath(rootUri, entry.name); if (!seen.has(uri)) { seen.add(uri); + const files = filesByType.get(root.type) ?? []; files.push({ uri, etag: entry.etag }); + filesByType.set(root.type, files); } } } } - if (files.length > 0) { - result.push({ uri: base, type: DiscoveredType.AgentInstruction, files: files.sort(compareDiscoveredFile) }); + + for (const [type, files] of filesByType.entries()) { + if (files.length === 0) { + continue; + } + result.push({ uri: base, type, files: files.sort(compareDiscoveredFile) }); } } @@ -539,6 +557,37 @@ export class SessionCustomizationDiscovery extends Disposable { }; await findInstructions(stat, 0); result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile) }); + } else if (root.type === DiscoveredType.Hook) { + const files: IDiscoveredFile[] = []; + // hooks are recursively discovered as `*.json` under the root. + const findHooks = async (directoryStat: IFileStatWithMetadata, recursionLevel: number): Promise => { + throwIfCancelled(token); + + for (const child of directoryStat.children ?? []) { + throwIfCancelled(token); + + if (child.isFile) { + const name = child.name.toLowerCase(); + if (name.endsWith(HOOK_FILE_SUFFIX) && !seen.has(child.resource)) { + seen.add(child.resource); + files.push({ uri: child.resource, etag: child.etag }); + } + } else if (child.isDirectory && recursionLevel < MAX_HOOKS_RECURSION_DEPTH) { + let childStat: IFileStatWithMetadata | undefined = undefined; + try { + childStat = await this._fileService.resolve(child.resource, { resolveMetadata: true }); + } catch { + // Ignore unreadable subdirectories. + } + if (childStat) { + await findHooks(childStat, recursionLevel + 1); + } + } + } + }; + + await findHooks(stat, 0); + result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile) }); } else { this._logService.warn(`[SessionCustomizationDiscovery] Unrecognized root type '${root.type}' for root '${rootUri.toString()}'`); } @@ -552,6 +601,7 @@ export const _internal = { INSTRUCTION_FILE_SUFFIX, SKILL_FILENAME, searchRoots, + fixedDiscoveryFiles, agentInstructions, }; diff --git a/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts b/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts index 378b69009cc312..5738fbaa13edf5 100644 --- a/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts +++ b/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts @@ -32,6 +32,7 @@ function pluginDirForType(type: DiscoveredType): string | undefined { case DiscoveredType.Agent: return 'agents'; case DiscoveredType.Skill: return 'skills'; case DiscoveredType.Instruction: return 'rules'; + case DiscoveredType.Hook: return 'hooks'; case DiscoveredType.AgentInstruction: return undefined; } } diff --git a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts index fd2b1bd1e5c12e..8697c5462667be 100644 --- a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts @@ -158,11 +158,13 @@ suite('SessionCustomizationDiscovery', () => { await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent'); await seed('/workspace/.github/skills/bar/SKILL.md', 'workspace skill'); await seed('/workspace/.github/instructions/rules.instructions.md', 'workspace instruction'); + await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}'); await seed('/workspace/.github/copilot-instructions.md', 'workspace copilot instructions'); await seed('/workspace/.claude/CLAUDE.md', 'workspace claude instruction'); await seed('/home/.copilot/agents/user.agent.md', 'user agent'); await seed('/home/.agents/skills/user-skill/SKILL.md', 'user skill'); await seed('/home/.copilot/instructions/user.instructions.md', 'user instruction'); + await seed('/home/.copilot/hooks/post-tool.json', '{"PostToolUse": []}'); await seed('/home/.copilot/copilot-instructions.md', 'user copilot instructions'); const watchCalls: Array<{ resource: string; recursive: boolean }> = []; @@ -187,10 +189,12 @@ suite('SessionCustomizationDiscovery', () => { assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'agents').toString()), false); assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'skills').toString()), true); assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'instructions').toString()), true); + assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'hooks').toString()), true); assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot').toString()), false); assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'agents').toString()), false); assert.strictEqual(watched.get(URI.joinPath(userHome, '.agents', 'skills').toString()), true); assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'instructions').toString()), true); + assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'hooks').toString()), true); }); test('refresh keeps existing watchers when discovered roots are unchanged', async () => { @@ -354,14 +358,17 @@ suite('SessionCustomizationDiscovery', () => { assert.ok(directories.some(directory => directory.type === DiscoveredType.Agent)); }); - test('discovers agents, skills, and instructions across workspace and home roots', async () => { + test('discovers agents, skills, instructions, and hooks across workspace and home roots', async () => { const wsAgent = await seed('/workspace/.github/agents/foo.agent.md', 'agent body'); const wsSkill = await seed('/workspace/.github/skills/bar/SKILL.md', 'skill body'); const wsInstr = await seed('/workspace/.github/instructions/baz.instructions.md', 'instr body'); + const wsHook = await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}'); const userAgent = await seed('/home/.copilot/agents/qux.agent.md', 'user agent'); const userSkill = await seed('/home/.agents/skills/zap/SKILL.md', 'user skill'); + const userHook = await seed('/home/.copilot/hooks/post-tool.json', '{"PostToolUse": []}'); // Noise that should not be picked up await seed('/workspace/.github/agents/not-an-agent.txt', 'ignored'); + await seed('/workspace/.github/hooks/not-a-hook.md', 'ignored'); const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); const directories = await discovery.scan(CancellationToken.None); @@ -369,14 +376,67 @@ suite('SessionCustomizationDiscovery', () => { assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ { uri: userAgent, type: DiscoveredType.Agent }, + { uri: userHook, type: DiscoveredType.Hook }, { uri: userSkill, type: DiscoveredType.Skill }, { uri: wsAgent, type: DiscoveredType.Agent }, + { uri: wsHook, type: DiscoveredType.Hook }, { uri: wsInstr, type: DiscoveredType.Instruction }, { uri: wsSkill, type: DiscoveredType.Skill }, ].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString()))); assert.ok(directories.some(directory => directory.uri.toString() === URI.joinPath(workspace, '.github', 'agents').toString())); }); + test('discovers nested .json hook files', async () => { + const nestedWsHook = await seed('/workspace/.github/hooks/team/security/pre-tool.json', '{"PreToolUse": []}'); + const nestedUserHook = await seed('/home/.copilot/hooks/domain/tools/post-tool.json', '{"PostToolUse": []}'); + + const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); + const files = (await discovery.scan(CancellationToken.None)).flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))); + + assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ + { uri: nestedUserHook, type: DiscoveredType.Hook }, + { uri: nestedWsHook, type: DiscoveredType.Hook }, + ].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString()))); + }); + + test('discovers hook settings files from fixed workspace locations', async () => { + const githubSettings = await seed('/workspace/.github/copilot/settings.json', '{"hooks": {"PreToolUse": []}}'); + const githubLocalSettings = await seed('/workspace/.github/copilot/settings.local.json', '{"hooks": {"PostToolUse": []}}'); + const claudeSettings = await seed('/workspace/.claude/settings.json', '{"hooks": {"SessionStart": []}}'); + const claudeLocalSettings = await seed('/workspace/.claude/settings.local.json', '{"hooks": {"SessionEnd": []}}'); + await seed('/workspace/.github/copilot/settings.dev.json', '{"hooks": {"Ignored": []}}'); + + const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); + const files = (await discovery.scan(CancellationToken.None)).flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))); + + assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ + { uri: claudeLocalSettings, type: DiscoveredType.Hook }, + { uri: claudeSettings, type: DiscoveredType.Hook }, + { uri: githubLocalSettings, type: DiscoveredType.Hook }, + { uri: githubSettings, type: DiscoveredType.Hook }, + ].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString()))); + }); + + test('fires onDidChange when fixed hook settings file is modified', async () => { + await seed('/workspace/.github/copilot/settings.json', '{"hooks": {"PreToolUse": []}}'); + + const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); + await discovery.scan(CancellationToken.None); + await timeout(50); + + let changeCount = 0; + const fired = new DeferredPromise(); + disposables.add(discovery.onDidChange(() => { + changeCount++; + fired.complete(); + })); + + await seed('/workspace/.github/copilot/settings.json', '{"hooks": {"PreToolUse": [{"command": "echo test"}]}}'); + await raceTimeout(fired.p, 500); + + assert.strictEqual(changeCount, 1, 'expected onDidChange to fire when fixed hook settings file is modified'); + }); + test('excludes exact-case README.md inside agent folders', async () => { const wsAgent = await seed('/workspace/.github/agents/foo.agent.md', 'agent body'); const wsPlainAgent = await seed('/workspace/.github/agents/plain.md', 'plain agent body'); @@ -558,6 +618,7 @@ suite('SessionPluginBundler', () => { await seed('/workspace/.github/agents/foo.agent.md', 'agent body'); await seed('/workspace/.github/skills/bar/SKILL.md', 'skill body'); await seed('/workspace/.github/instructions/baz.instructions.md', 'instr body'); + await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}'); const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); @@ -580,6 +641,9 @@ suite('SessionPluginBundler', () => { const instr = await fileService.readFile(URI.joinPath(root, 'rules', 'baz.instructions.md')); assert.strictEqual(instr.value.toString(), 'instr body'); + + const hook = await fileService.readFile(URI.joinPath(root, 'hooks', 'pre-tool.json')); + assert.strictEqual(hook.value.toString(), '{"PreToolUse": []}'); }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index 655e1e4d1f421e..e04af081e60c87 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -301,6 +301,8 @@ function toPromptsType(type: ChildCustomization['type']): PromptsType | undefine return PromptsType.instructions; case CustomizationType.Prompt: return PromptsType.prompt; + case CustomizationType.Hook: + return PromptsType.hook; default: return undefined; }