Add support for agent-scoped hooks (#299029)

This commit is contained in:
Paul
2026-03-04 13:50:19 -08:00
committed by GitHub
parent 1b44525f0e
commit ffe529eced
19 changed files with 1982 additions and 115 deletions

View File

@@ -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<PromptsStorage>();
private readonly collapsedGroups = new Set<string>();
private readonly dropdownActionDisposables = this._register(new DisposableStore());
private readonly delayedFilter = new Delayer<void>(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();
}

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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`);

View File

@@ -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<string, unknown>;
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<string, unknown>);
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<string, unknown>): Record<string, unknown> {
// 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;
}

View File

@@ -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<Record<HookType, readonly IHookCommand[]>> = { ...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<string, string> = {
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<string, unknown>, 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<string, unknown>;
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<string, unknown>);
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<string, unknown>): Record<string, unknown> {
// 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<string, unknown> = {};
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<string, IHookCommand[]> = {};
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;
}

View File

@@ -167,6 +167,10 @@ export const customAgentAttributes: Record<string, IAttributeDefinition> = {
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`).

View File

@@ -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<CompletionList | undefined> {
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) {

View File

@@ -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)) {

View File

@@ -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, string[]> = {
[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:

View File

@@ -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') {

View File

@@ -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.
*/

View File

@@ -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<ICustomAgent> => {
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 };
})
);

View File

@@ -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<string, unknown>)[HookType.SubagentStop] = remapped[HookType.SubagentStop]
? [...remapped[HookType.SubagentStop], ...stopHooks]
: stopHooks;
(remapped as Record<string, unknown>)[HookType.Stop] = undefined;
}
collectedHooks = mergeHooks(collectedHooks, remapped);
}
// Build the agent request
const agentRequest: IChatAgentRequest = {
sessionResource: invocation.context.sessionResource,

View File

@@ -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);
});
});
});

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -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<string, import('../../../common/promptSyntax/promptFileParser.js').IValue>): 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);
});
});
});

View File

@@ -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 }
},