Skip to content

Add support for agent-scoped hooks#299029

Merged
pwang347 merged 14 commits intomainfrom
pawang/customAgentHooks
Mar 4, 2026
Merged

Add support for agent-scoped hooks#299029
pwang347 merged 14 commits intomainfrom
pawang/customAgentHooks

Conversation

@pwang347
Copy link
Member

@pwang347 pwang347 commented Mar 3, 2026

Copilot AI review requested due to automatic review settings March 3, 2026 18:44
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@pwang347 pwang347 marked this pull request as ready for review March 4, 2026 20:56
@pwang347 pwang347 requested a review from Copilot March 4, 2026 20:56
@vs-code-engineering vs-code-engineering bot added this to the 1.111.0 milestone Mar 4, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (4)

src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts:405

  • The hook-event insertion snippet on empty lines uses \n - ... / \n command: ..., which is only correct if the hook key is at column 1. Inside a hooks: map the key is indented, so the subsequent lines should be indented relative to that (e.g. \n - ... and \n command: ...). Adjust the snippet indentation based on the hook key indentation to avoid generating malformed YAML.

This issue also appears on line 314 of the same file.

			if (isEmptyLine) {
				// On empty lines, insert a full hook snippet with command placeholder
				insertText = [
					`${hookName}:`,
					`  - type: command`,
					`    command: "$1"`,
				].join('\n');

src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts:660

  • validateHookCommand treats bash/powershell as command-providing fields for non-Copilot targets (validCommandFields includes them), but the missing-command error message says the command must specify one of command, windows, linux, or osx. Either remove bash/powershell from the accepted command fields for VS Code agents, or update the error message to include them so validation feedback matches what the parser/runtime accepts.
		// 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));
			}
		}

src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts:541

  • normalizeForResolve forces type: 'command' when type is omitted. In agent frontmatter, the new validator flags missing type as an error, but parseSubagentHooksFromYaml will still resolve and execute these hooks at runtime. Consider aligning runtime parsing with validation (e.g., only default the type when parsing Claude-style nested matcher entries / Claude target, or otherwise require explicit type).
/**
 * 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;

src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts:322

  • Hook event completions insert YAML snippets with fixed indentation (' - type: command', ' command: ...'). When completing inside the hooks: map (where keys are already indented), this produces invalid indentation (the list item should be indented deeper than the hook key). Consider computing the base indent from the current line / hook key indent and emitting -/field lines with the correct additional indentation.
			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');

@pwang347 pwang347 merged commit ffe529e into main Mar 4, 2026
20 checks passed
@pwang347 pwang347 deleted the pawang/customAgentHooks branch March 4, 2026 21:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants