Move hook execution to extension (#294215)

* Refactor hook execution

* Fix compilation: add IExtHostHooks import, remove unused IHookResult, inline ChatRequestHooks type

* Move hooks property to chatHooks proposal, sync DTS

* cleanup

* Remove dead hook execution code: proxy, RPC, output channel, progress events

All hook execution now happens in the extension via NodeHookExecutor.
HooksExecutionService is now a pure registry (registerHooks/getHooksForSession).

Removed:
- executeHook, setProxy, onDidHookProgress from service
- IHooksExecutionProxy, IHookProgressEvent, HookAbortError, formatHookErrorMessage
- hooksCommandTypes.ts, hooksTypes.ts (dead type files)
- mainThreadHooks proxy setup
- extHostHooksNode., extHostHooksWorker.
- ExtHostHooksShape. protocol
- IExtHostHooks DI registrations
- ChatHooksProgressContribution
- All associated test files

* Remove HooksExecutionService entirely

The service was only a registry for session hooks, but hooks are already
passed directly on the chat request DTO. The registerHooks/getHooksForSession
pattern was redundant.

* Restore modelName support in chatSubagentContentPart that was accidentally removed during merge

* Revert unrelated tabIndex change on chatSubagentContentPart

* Remove empty hooks ext host infrastructure

Delete IExtHostHooks, NodeExtHostHooks, WorkerExtHostHooks,
MainThreadHooks, ExtHostHooksShape, MainThreadHooksShape -
all were empty stubs after hook execution moved to extension.

* Remove mainThreadHooks import from extensionHost.contribution

* Fix DTS comments: env and timeoutSec are values, not implementation promises
This commit is contained in:
Rob Lourens
2026-02-10 22:59:11 +00:00
committed by GitHub
parent 9f6e415379
commit 11bbecdc9c
21 changed files with 67 additions and 1572 deletions

View File

@@ -45,7 +45,7 @@ const _allApiProposals = {
},
chatHooks: {
proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts',
version: 3
version: 5
},
chatOutputRenderer: {
proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts',

View File

@@ -98,7 +98,6 @@ import './mainThreadChatOutputRenderer.js';
import './mainThreadChatSessions.js';
import './mainThreadDataChannels.js';
import './mainThreadMeteredConnection.js';
import './mainThreadHooks.js';
export class ExtensionPoints implements IWorkbenchContribution {

View File

@@ -1,43 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { URI, UriComponents } from '../../../base/common/uri.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js';
import { ExtHostContext, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js';
import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js';
import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js';
import { IHooksExecutionProxy, IHooksExecutionService } from '../../contrib/chat/common/hooks/hooksExecutionService.js';
import { HookTypeValue, IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js';
import { CancellationToken } from '../../../base/common/cancellation.js';
@extHostNamedCustomer(MainContext.MainThreadHooks)
export class MainThreadHooks extends Disposable implements MainThreadHooksShape {
constructor(
extHostContext: IExtHostContext,
@IHooksExecutionService private readonly _hooksExecutionService: IHooksExecutionService,
) {
super();
const extHostProxy = extHostContext.getProxy(ExtHostContext.ExtHostHooks);
const proxy: IHooksExecutionProxy = {
runHookCommand: async (hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise<IHookCommandResult> => {
const result = await extHostProxy.$runHookCommand(hookCommand, input, token);
return {
kind: result.kind as HookCommandResultKind,
result: result.result
};
}
};
this._hooksExecutionService.setProxy(proxy);
}
async $executeHook(hookType: string, sessionResource: UriComponents, input: unknown, token: CancellationToken): Promise<IHookResult[]> {
const uri = URI.revive(sessionResource);
return this._hooksExecutionService.executeHook(hookType as HookTypeValue, uri, { input, token });
}
}

View File

@@ -65,7 +65,6 @@ import { IExtHostConsumerFileSystem } from './extHostFileSystemConsumer.js';
import { ExtHostFileSystemEventService, FileSystemWatcherCreateOptions } from './extHostFileSystemEventService.js';
import { IExtHostFileSystemInfo } from './extHostFileSystemInfo.js';
import { IExtHostInitDataService } from './extHostInitDataService.js';
import { IExtHostHooks } from './extHostHooks.js';
import { ExtHostInteractive } from './extHostInteractive.js';
import { ExtHostLabelService } from './extHostLabelService.js';
import { ExtHostLanguageFeatures } from './extHostLanguageFeatures.js';
@@ -245,7 +244,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
const extHostEmbeddings = rpcProtocol.set(ExtHostContext.ExtHostEmbeddings, new ExtHostEmbeddings(rpcProtocol));
rpcProtocol.set(ExtHostContext.ExtHostMcp, accessor.get(IExtHostMpcService));
rpcProtocol.set(ExtHostContext.ExtHostHooks, accessor.get(IExtHostHooks));
// Check that no named customers are missing
const expected = Object.values<ProxyIdentifier<any>>(ExtHostContext);
@@ -257,7 +255,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
const extHostMessageService = new ExtHostMessageService(rpcProtocol, extHostLogService);
const extHostDialogs = new ExtHostDialogs(rpcProtocol);
const extHostChatStatus = new ExtHostChatStatus(rpcProtocol);
const extHostHooks = accessor.get(IExtHostHooks);
// Register API-ish commands
ExtHostApiCommands.register(extHostCommands);
@@ -1661,10 +1658,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
checkProposedApiEnabled(extension, 'chatPromptFiles');
return extHostChatAgents2.registerPromptFileProvider(extension, PromptsType.skill, provider);
},
async executeHook(hookType: vscode.ChatHookType, options: vscode.ChatHookExecutionOptions, token?: vscode.CancellationToken): Promise<vscode.ChatHookResult[]> {
checkProposedApiEnabled(extension, 'chatHooks');
return extHostHooks.executeHook(hookType, options, token);
},
};
// namespace: lm

View File

@@ -99,9 +99,6 @@ import { IExtHostDocumentSaveDelegate } from './extHostDocumentData.js';
import { TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js';
import * as tasks from './shared/tasks.js';
import { PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js';
import { IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js';
import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js';
import { IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js';
export type IconPathDto =
| UriComponents
@@ -3234,12 +3231,6 @@ export interface IStartMcpOptions {
errorOnUserInteraction?: boolean;
}
export type IHookCommandDto = Dto<IHookCommand>;
export interface ExtHostHooksShape {
$runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise<IHookCommandResult>;
}
export interface ExtHostMcpShape {
$substituteVariables(workspaceFolder: UriComponents | undefined, value: McpServerLaunch.Serialized): Promise<McpServerLaunch.Serialized>;
$resolveMcpLaunch(collectionId: string, label: string): Promise<McpServerLaunch.Serialized | undefined>;
@@ -3295,10 +3286,6 @@ export interface MainThreadMcpShape {
export interface MainThreadDataChannelsShape extends IDisposable {
}
export interface MainThreadHooksShape extends IDisposable {
$executeHook(hookType: string, sessionResource: UriComponents, input: unknown, token: CancellationToken): Promise<IHookResult[]>;
}
export interface ExtHostDataChannelsShape {
$onDidReceiveData(channelId: string, data: unknown): void;
}
@@ -3535,7 +3522,6 @@ export const MainContext = {
MainThreadChatStatus: createProxyIdentifier<MainThreadChatStatusShape>('MainThreadChatStatus'),
MainThreadAiSettingsSearch: createProxyIdentifier<MainThreadAiSettingsSearchShape>('MainThreadAiSettingsSearch'),
MainThreadDataChannels: createProxyIdentifier<MainThreadDataChannelsShape>('MainThreadDataChannels'),
MainThreadHooks: createProxyIdentifier<MainThreadHooksShape>('MainThreadHooks'),
MainThreadChatSessions: createProxyIdentifier<MainThreadChatSessionsShape>('MainThreadChatSessions'),
MainThreadChatOutputRenderer: createProxyIdentifier<MainThreadChatOutputRendererShape>('MainThreadChatOutputRenderer'),
MainThreadChatContext: createProxyIdentifier<MainThreadChatContextShape>('MainThreadChatContext'),
@@ -3615,7 +3601,6 @@ export const ExtHostContext = {
ExtHostMeteredConnection: createProxyIdentifier<ExtHostMeteredConnectionShape>('ExtHostMeteredConnection'),
ExtHostLocalization: createProxyIdentifier<ExtHostLocalizationShape>('ExtHostLocalization'),
ExtHostMcp: createProxyIdentifier<ExtHostMcpShape>('ExtHostMcp'),
ExtHostHooks: createProxyIdentifier<ExtHostHooksShape>('ExtHostHooks'),
ExtHostDataChannels: createProxyIdentifier<ExtHostDataChannelsShape>('ExtHostDataChannels'),
ExtHostChatSessions: createProxyIdentifier<ExtHostChatSessionsShape>('ExtHostChatSessions'),
};

View File

@@ -1,21 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type * as vscode from 'vscode';
import { CancellationToken } from '../../../base/common/cancellation.js';
import { createDecorator } from '../../../platform/instantiation/common/instantiation.js';
import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js';
import { ExtHostHooksShape } from './extHost.protocol.js';
export const IExtHostHooks = createDecorator<IExtHostHooks>('IExtHostHooks');
export interface IChatHookExecutionOptions {
readonly input?: unknown;
readonly toolInvocationToken: unknown;
}
export interface IExtHostHooks extends ExtHostHooksShape {
executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise<vscode.ChatHookResult[]>;
}

View File

@@ -16,6 +16,7 @@ import { parse, revive } from '../../../base/common/marshalling.js';
import { MarshalledId } from '../../../base/common/marshallingIds.js';
import { Mimes } from '../../../base/common/mime.js';
import { cloneAndChange } from '../../../base/common/objects.js';
import { OS } from '../../../base/common/platform.js';
import { IPrefixTreeNode, WellDefinedPrefixTree } from '../../../base/common/prefixTree.js';
import { basename } from '../../../base/common/resources.js';
import { ThemeIcon } from '../../../base/common/themables.js';
@@ -45,7 +46,7 @@ import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCom
import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js';
import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js';
import { ChatAgentLocation } from '../../contrib/chat/common/constants.js';
import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js';
import { IChatRequestHooks, IHookCommand, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js';
import { IToolInvocationContext, IToolResult, IToolResultInputOutputDetails, IToolResultOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../contrib/chat/common/tools/languageModelToolsService.js';
import * as chatProvider from '../../contrib/chat/common/languageModels.js';
import { IChatMessageDataPart, IChatResponseDataPart, IChatResponsePromptTsxPart, IChatResponseTextPart } from '../../contrib/chat/common/languageModels.js';
@@ -3437,6 +3438,7 @@ export namespace ChatAgentRequest {
subAgentName: request.subAgentName,
parentRequestId: request.parentRequestId,
hasHooksEnabled: request.hasHooksEnabled ?? false,
hooks: request.hooks ? ChatRequestHooksConverter.to(request.hooks) : undefined,
};
if (!isProposedApiEnabled(extension, 'chatParticipantPrivate')) {
@@ -3464,6 +3466,8 @@ export namespace ChatAgentRequest {
delete (requestWithAllProps as any).parentRequestId;
// eslint-disable-next-line local/code-no-any-casts
delete (requestWithAllProps as any).hasHooksEnabled;
// eslint-disable-next-line local/code-no-any-casts
delete (requestWithAllProps as any).hooks;
}
if (!isProposedApiEnabled(extension, 'chatParticipantAdditions')) {
@@ -4082,13 +4086,39 @@ export namespace SourceControlInputBoxValidationType {
}
}
export namespace ChatHookResult {
export function to(result: IHookResult): vscode.ChatHookResult {
export namespace ChatRequestHooksConverter {
export function to(hooks: IChatRequestHooks): vscode.ChatRequestHooks {
const result: Record<string, vscode.ChatHookCommand[]> = {};
for (const [hookType, commands] of Object.entries(hooks)) {
if (!commands || commands.length === 0) {
continue;
}
const converted: vscode.ChatHookCommand[] = [];
for (const cmd of commands) {
const resolved = ChatHookCommand.to(cmd);
if (resolved) {
converted.push(resolved);
}
}
if (converted.length > 0) {
result[hookType] = converted;
}
}
return result;
}
}
export namespace ChatHookCommand {
export function to(hook: IHookCommand): vscode.ChatHookCommand | undefined {
const command = resolveEffectiveCommand(hook, OS);
if (!command) {
return undefined;
}
return {
resultKind: result.resultKind,
stopReason: result.stopReason,
warningMessage: result.warningMessage,
output: result.output,
command,
cwd: hook.cwd,
env: hook.env,
timeoutSec: hook.timeoutSec,
};
}
}

View File

@@ -31,8 +31,6 @@ import { IExtHostMpcService } from '../common/extHostMcp.js';
import { NodeExtHostMpcService } from './extHostMcpNode.js';
import { IExtHostAuthentication } from '../common/extHostAuthentication.js';
import { NodeExtHostAuthentication } from './extHostAuthentication.js';
import { IExtHostHooks } from '../common/extHostHooks.js';
import { NodeExtHostHooks } from './extHostHooksNode.js';
// #########################################################################
// ### ###
@@ -55,4 +53,3 @@ registerSingleton(IExtHostTerminalService, ExtHostTerminalService, Instantiation
registerSingleton(IExtHostTunnelService, NodeExtHostTunnelService, InstantiationType.Eager);
registerSingleton(IExtHostVariableResolverProvider, NodeExtHostVariableResolverProviderService, InstantiationType.Eager);
registerSingleton(IExtHostMpcService, NodeExtHostMpcService, InstantiationType.Eager);
registerSingleton(IExtHostHooks, NodeExtHostHooks, InstantiationType.Eager);

View File

@@ -1,196 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type * as vscode from 'vscode';
import { spawn } from 'child_process';
import { homedir } from 'os';
import * as nls from '../../../nls.js';
import { disposableTimeout } from '../../../base/common/async.js';
import { CancellationToken } from '../../../base/common/cancellation.js';
import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js';
import { OS } from '../../../base/common/platform.js';
import { URI, isUriComponents } from '../../../base/common/uri.js';
import { ILogService } from '../../../platform/log/common/log.js';
import { HookTypeValue, getEffectiveCommandSource, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js';
import { isToolInvocationContext, IToolInvocationContext } from '../../contrib/chat/common/tools/languageModelToolsService.js';
import { IHookCommandDto, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js';
import { IChatHookExecutionOptions, IExtHostHooks } from '../common/extHostHooks.js';
import { IExtHostRpcService } from '../common/extHostRpcService.js';
import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js';
import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js';
import * as typeConverters from '../common/extHostTypeConverters.js';
const SIGKILL_DELAY_MS = 5000;
export class NodeExtHostHooks implements IExtHostHooks {
private readonly _mainThreadProxy: MainThreadHooksShape;
constructor(
@IExtHostRpcService extHostRpc: IExtHostRpcService,
@ILogService private readonly _logService: ILogService
) {
this._mainThreadProxy = extHostRpc.getProxy(MainContext.MainThreadHooks);
}
async executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise<vscode.ChatHookResult[]> {
if (!options.toolInvocationToken || !isToolInvocationContext(options.toolInvocationToken)) {
this._logService.error('[NodeExtHostHooks] Invalid or missing tool invocation token');
return [];
}
const context = options.toolInvocationToken as IToolInvocationContext;
const results = await this._mainThreadProxy.$executeHook(hookType, context.sessionResource, options.input, token ?? CancellationToken.None);
return results.map(r => typeConverters.ChatHookResult.to(r as IHookResult));
}
async $runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise<IHookCommandResult> {
this._logService.debug(`[ExtHostHooks] Running hook command: ${JSON.stringify(hookCommand)}`);
try {
return await this._executeCommand(hookCommand, input, token);
} catch (err) {
return {
kind: HookCommandResultKind.Error,
result: err instanceof Error ? err.message : String(err)
};
}
}
private _executeCommand(hook: IHookCommandDto, input: unknown, token?: CancellationToken): Promise<IHookCommandResult> {
const home = homedir();
const cwdUri = hook.cwd ? URI.revive(hook.cwd) : undefined;
const cwd = cwdUri ? cwdUri.fsPath : home;
// Resolve the effective command for the current platform
// This applies windows/linux/osx overrides and falls back to command
const effectiveCommand = resolveEffectiveCommand(hook as Parameters<typeof resolveEffectiveCommand>[0], OS);
if (!effectiveCommand) {
return Promise.resolve({
kind: HookCommandResultKind.NonBlockingError,
result: nls.localize('noCommandForPlatform', "No command specified for the current platform")
});
}
// Execute the command, preserving legacy behavior for explicit shell types:
// - powershell source: run through PowerShell so PowerShell-specific commands work
// - bash source: run through bash so bash-specific commands work
// - otherwise: use default shell via spawn with shell: true
const commandSource = getEffectiveCommandSource(hook as Parameters<typeof getEffectiveCommandSource>[0], OS);
let shellExecutable: string | undefined;
let shellArgs: string[] | undefined;
if (commandSource === 'powershell') {
shellExecutable = 'powershell.exe';
shellArgs = ['-Command', effectiveCommand];
} else if (commandSource === 'bash') {
shellExecutable = 'bash';
shellArgs = ['-c', effectiveCommand];
}
const child = shellExecutable && shellArgs
? spawn(shellExecutable, shellArgs, {
stdio: 'pipe',
cwd,
env: { ...process.env, ...hook.env },
})
: spawn(effectiveCommand, [], {
stdio: 'pipe',
cwd,
env: { ...process.env, ...hook.env },
shell: true,
});
return new Promise((resolve, reject) => {
const stdout: string[] = [];
const stderr: string[] = [];
let exitCode: number | null = null;
let exited = false;
const disposables = new DisposableStore();
const sigkillTimeout = disposables.add(new MutableDisposable());
const killWithEscalation = () => {
if (exited) {
return;
}
child.kill('SIGTERM');
sigkillTimeout.value = disposableTimeout(() => {
if (!exited) {
child.kill('SIGKILL');
}
}, SIGKILL_DELAY_MS);
};
const cleanup = () => {
exited = true;
disposables.dispose();
};
// Collect output
child.stdout.on('data', data => stdout.push(data.toString()));
child.stderr.on('data', data => stderr.push(data.toString()));
// Set up timeout (default 30 seconds)
disposables.add(disposableTimeout(killWithEscalation, (hook.timeoutSec ?? 30) * 1000));
// Set up cancellation
if (token) {
disposables.add(token.onCancellationRequested(killWithEscalation));
}
// Write input to stdin
if (input !== undefined && input !== null) {
try {
// Use a replacer to convert URI values to filesystem paths.
// URIs arrive as UriComponents objects via the RPC boundary.
child.stdin.write(JSON.stringify(input, (_key, value) => {
if (isUriComponents(value)) {
return URI.revive(value).fsPath;
}
return value;
}));
} catch {
// Ignore stdin write errors
}
}
child.stdin.end();
// Capture exit code
child.on('exit', code => { exitCode = code; });
// Resolve on close (after streams flush)
child.on('close', () => {
cleanup();
const code = exitCode ?? 1;
const stdoutStr = stdout.join('');
const stderrStr = stderr.join('');
if (code === 0) {
// Success - try to parse stdout as JSON, otherwise return as string
let result: string | object = stdoutStr;
try {
result = JSON.parse(stdoutStr);
} catch {
// Keep as string if not valid JSON
}
resolve({ kind: HookCommandResultKind.Success, result });
} else if (code === 2) {
// Blocking error - show stderr to model and stop processing
resolve({ kind: HookCommandResultKind.Error, result: stderrStr });
} else {
// Non-blocking error - show stderr to user only
resolve({ kind: HookCommandResultKind.NonBlockingError, result: stderrStr });
}
});
child.on('error', err => {
cleanup();
reject(err);
});
});
}
}

View File

@@ -1,137 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { NullLogService } from '../../../../platform/log/common/log.js';
import { NodeExtHostHooks } from '../../node/extHostHooksNode.js';
import { IHookCommandDto, MainThreadHooksShape } from '../../common/extHost.protocol.js';
import { HookCommandResultKind } from '../../../contrib/chat/common/hooks/hooksCommandTypes.js';
import { IHookResult } from '../../../contrib/chat/common/hooks/hooksTypes.js';
import { IExtHostRpcService } from '../../common/extHostRpcService.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
function createHookCommandDto(command: string, options?: Partial<Omit<IHookCommandDto, 'type' | 'command'>>): IHookCommandDto {
return {
type: 'command',
command,
...options,
};
}
function createMockExtHostRpcService(mainThreadProxy: MainThreadHooksShape): IExtHostRpcService {
return {
_serviceBrand: undefined,
getProxy<T>(): T {
return mainThreadProxy as unknown as T;
},
set<T, R extends T>(_identifier: unknown, instance: R): R {
return instance;
},
dispose(): void { },
assertRegistered(): void { },
drain(): Promise<void> { return Promise.resolve(); },
} as IExtHostRpcService;
}
suite.skip('ExtHostHooks', () => {
ensureNoDisposablesAreLeakedInTestSuite();
let hooksService: NodeExtHostHooks;
setup(() => {
const mockMainThreadProxy: MainThreadHooksShape = {
$executeHook: async (): Promise<IHookResult[]> => {
return [];
},
dispose: () => { }
};
const mockRpcService = createMockExtHostRpcService(mockMainThreadProxy);
hooksService = new NodeExtHostHooks(mockRpcService, new NullLogService());
});
test('$runHookCommand runs command and returns success result', async () => {
const hookCommand = createHookCommandDto('echo "hello world"');
const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None);
assert.strictEqual(result.kind, HookCommandResultKind.Success);
assert.strictEqual((result.result as string).trim(), 'hello world');
});
test('$runHookCommand parses JSON output', async () => {
const hookCommand = createHookCommandDto('echo \'{"key": "value"}\'');
const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None);
assert.strictEqual(result.kind, HookCommandResultKind.Success);
assert.deepStrictEqual(result.result, { key: 'value' });
});
test('$runHookCommand returns non-blocking error for exit code 1', async () => {
const hookCommand = createHookCommandDto('exit 1');
const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None);
assert.strictEqual(result.kind, HookCommandResultKind.NonBlockingError);
});
test('$runHookCommand returns blocking error for exit code 2', async () => {
const hookCommand = createHookCommandDto('exit 2');
const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None);
assert.strictEqual(result.kind, HookCommandResultKind.Error);
});
test('$runHookCommand captures stderr on non-blocking error', async () => {
const hookCommand = createHookCommandDto('echo "error message" >&2 && exit 1');
const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None);
assert.strictEqual(result.kind, HookCommandResultKind.NonBlockingError);
assert.strictEqual((result.result as string).trim(), 'error message');
});
test('$runHookCommand captures stderr on blocking error', async () => {
const hookCommand = createHookCommandDto('echo "blocking error" >&2 && exit 2');
const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None);
assert.strictEqual(result.kind, HookCommandResultKind.Error);
assert.strictEqual((result.result as string).trim(), 'blocking error');
});
test('$runHookCommand passes input to stdin as JSON', async () => {
const hookCommand = createHookCommandDto('cat');
const input = { tool: 'bash', args: { command: 'ls' } };
const result = await hooksService.$runHookCommand(hookCommand, input, CancellationToken.None);
assert.strictEqual(result.kind, HookCommandResultKind.Success);
assert.deepStrictEqual(result.result, input);
});
test('$runHookCommand returns non-blocking error for invalid command', async () => {
const hookCommand = createHookCommandDto('/nonexistent/command/that/does/not/exist');
const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None);
// Invalid commands typically return non-zero exit codes (127 for command not found)
// which are treated as non-blocking errors unless it's exit code 2
assert.strictEqual(result.kind, HookCommandResultKind.NonBlockingError);
});
test('$runHookCommand uses custom environment variables', async () => {
const hookCommand = createHookCommandDto('echo $MY_VAR', { env: { MY_VAR: 'custom_value' } });
const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None);
assert.strictEqual(result.kind, HookCommandResultKind.Success);
assert.strictEqual((result.result as string).trim(), 'custom_value');
});
test('$runHookCommand uses custom cwd', async () => {
const hookCommand = createHookCommandDto('pwd', { cwd: URI.file('/tmp') });
const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None);
assert.strictEqual(result.kind, HookCommandResultKind.Success);
// The result should contain /tmp or /private/tmp (macOS symlink)
assert.ok((result.result as string).includes('tmp'));
});
});

View File

@@ -8,12 +8,10 @@ import { InstantiationType, registerSingleton } from '../../../platform/instanti
import { ILogService } from '../../../platform/log/common/log.js';
import { ExtHostAuthentication, IExtHostAuthentication } from '../common/extHostAuthentication.js';
import { IExtHostExtensionService } from '../common/extHostExtensionService.js';
import { IExtHostHooks } from '../common/extHostHooks.js';
import { ExtHostLogService } from '../common/extHostLogService.js';
import { ExtensionStoragePaths, IExtensionStoragePaths } from '../common/extHostStoragePaths.js';
import { ExtHostTelemetry, IExtHostTelemetry } from '../common/extHostTelemetry.js';
import { ExtHostExtensionService } from './extHostExtensionService.js';
import { WorkerExtHostHooks } from './extHostHooksWorker.js';
// #########################################################################
// ### ###
@@ -26,4 +24,3 @@ registerSingleton(IExtHostAuthentication, ExtHostAuthentication, InstantiationTy
registerSingleton(IExtHostExtensionService, ExtHostExtensionService, InstantiationType.Eager);
registerSingleton(IExtensionStoragePaths, ExtensionStoragePaths, InstantiationType.Eager);
registerSingleton(IExtHostTelemetry, new SyncDescriptor(ExtHostTelemetry, [true], true));
registerSingleton(IExtHostHooks, WorkerExtHostHooks, InstantiationType.Eager);

View File

@@ -1,50 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type * as vscode from 'vscode';
import { CancellationToken } from '../../../base/common/cancellation.js';
import { ILogService } from '../../../platform/log/common/log.js';
import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js';
import { isToolInvocationContext, IToolInvocationContext } from '../../contrib/chat/common/tools/languageModelToolsService.js';
import { IHookCommandDto, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js';
import { IChatHookExecutionOptions, IExtHostHooks } from '../common/extHostHooks.js';
import { IExtHostRpcService } from '../common/extHostRpcService.js';
import * as typeConverters from '../common/extHostTypeConverters.js';
import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js';
import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js';
export class WorkerExtHostHooks implements IExtHostHooks {
private readonly _mainThreadProxy: MainThreadHooksShape;
constructor(
@IExtHostRpcService extHostRpc: IExtHostRpcService,
@ILogService private readonly _logService: ILogService
) {
this._mainThreadProxy = extHostRpc.getProxy(MainContext.MainThreadHooks);
}
async executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise<vscode.ChatHookResult[]> {
if (!options.toolInvocationToken || !isToolInvocationContext(options.toolInvocationToken)) {
this._logService.error('[WorkerExtHostHooks] Invalid or missing tool invocation token');
return [];
}
const context = options.toolInvocationToken as IToolInvocationContext;
const results = await this._mainThreadProxy.$executeHook(hookType, context.sessionResource, options.input, token ?? CancellationToken.None);
return results.map(r => typeConverters.ChatHookResult.to(r as IHookResult));
}
async $runHookCommand(_hookCommand: IHookCommandDto, _input: unknown, _token: CancellationToken): Promise<IHookCommandResult> {
this._logService.debug('[WorkerExtHostHooks] Hook commands are not supported in web worker context');
// Web worker cannot run shell commands - return an error
return {
kind: HookCommandResultKind.Error,
result: 'Hook commands are not supported in web worker context'
};
}
}

View File

@@ -52,7 +52,6 @@ import { ILanguageModelsService, LanguageModelsService } from '../common/languag
import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js';
import { ILanguageModelToolsConfirmationService } from '../common/tools/languageModelToolsConfirmationService.js';
import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js';
import { HooksExecutionService, IHooksExecutionService } from '../common/hooks/hooksExecutionService.js';
import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js';
import { PromptsConfig } from '../common/promptSyntax/config/config.js';
import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME, CLAUDE_AGENTS_SOURCE_FOLDER, CLAUDE_RULES_SOURCE_FOLDER, DEFAULT_HOOK_FILE_PATHS } from '../common/promptSyntax/config/promptFileLocations.js';
@@ -1191,37 +1190,6 @@ class ChatResolverContribution extends Disposable {
}
}
class ChatHooksProgressContribution extends Disposable implements IWorkbenchContribution {
static readonly ID = 'workbench.contrib.chatHooksProgress';
constructor(
@IChatService private readonly chatService: IChatService,
@IHooksExecutionService hooksExecutionService: IHooksExecutionService,
) {
super();
this._register(hooksExecutionService.onDidHookProgress(event => {
const model = this.chatService.getSession(event.sessionResource);
if (!model) {
return;
}
const request = model.getRequests().at(-1);
if (!request) {
return;
}
this.chatService.appendProgress(request, {
kind: 'hook',
hookType: event.hookType,
stopReason: event.stopReason,
systemMessage: event.systemMessage,
});
}));
}
}
class ChatAgentSettingContribution extends Disposable implements IWorkbenchContribution {
static readonly ID = 'workbench.contrib.chatAgentSetting';
@@ -1528,7 +1496,6 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable {
Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory).registerEditorSerializer(ChatEditorInput.TypeID, ChatEditorInputSerializer);
registerWorkbenchContribution2(ChatResolverContribution.ID, ChatResolverContribution, WorkbenchPhase.BlockStartup);
registerWorkbenchContribution2(ChatHooksProgressContribution.ID, ChatHooksProgressContribution, WorkbenchPhase.AfterRestored);
registerWorkbenchContribution2(ChatLanguageModelsDataContribution.ID, ChatLanguageModelsDataContribution, WorkbenchPhase.BlockRestore);
registerWorkbenchContribution2(ChatSlashStaticSlashCommandsContribution.ID, ChatSlashStaticSlashCommandsContribution, WorkbenchPhase.Eventually);
@@ -1609,7 +1576,6 @@ registerSingleton(ICodeMapperService, CodeMapperService, InstantiationType.Delay
registerSingleton(IChatEditingService, ChatEditingService, InstantiationType.Delayed);
registerSingleton(IChatMarkdownAnchorService, ChatMarkdownAnchorService, InstantiationType.Delayed);
registerSingleton(ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService, InstantiationType.Delayed);
registerSingleton(IHooksExecutionService, HooksExecutionService, InstantiationType.Delayed);
registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed);
registerSingleton(IChatContextPickService, ChatContextPickService, InstantiationType.Delayed);
registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed);

View File

@@ -51,7 +51,6 @@ import { ILanguageModelToolsService } from '../tools/languageModelToolsService.j
import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js';
import { IPromptsService } from '../promptSyntax/service/promptsService.js';
import { IChatRequestHooks } from '../promptSyntax/hookSchema.js';
import { IHooksExecutionService } from '../hooks/hooksExecutionService.js';
const serializedChatKey = 'interactive.sessions';
@@ -156,7 +155,6 @@ export class ChatService extends Disposable implements IChatService {
@IChatSessionsService private readonly chatSessionService: IChatSessionsService,
@IMcpService private readonly mcpService: IMcpService,
@IPromptsService private readonly promptsService: IPromptsService,
@IHooksExecutionService private readonly hooksExecutionService: IHooksExecutionService,
) {
super();
@@ -911,10 +909,6 @@ export class ChatService extends Disposable implements IChatService {
this.logService.warn('[ChatService] Failed to collect hooks:', error);
}
if (collectedHooks) {
store.add(this.hooksExecutionService.registerHooks(model.sessionResource, collectedHooks));
}
const stopWatch = new StopWatch(false);
store.add(token.onCancellationRequested(() => {
this.trace('sendRequest', `Request for session ${model.sessionResource} was cancelled`);

View File

@@ -1,67 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* External hook types - types that cross the process boundary to/from spawned hook commands.
*
* "External" means these types define the contract between VS Code and the external hook
* command process.
*
* Internal types (in hooksTypes.ts) are used within VS Code.
*/
import { URI } from '../../../../../base/common/uri.js';
//#region Common Hook Types
/**
* Common properties added to all hook command inputs.
*/
export interface IHookCommandInput {
readonly timestamp: string;
readonly cwd: URI;
readonly sessionId: string;
readonly hookEventName: string;
readonly transcript_path?: URI;
}
/**
* Common output fields that can be present in any hook command result.
* These fields control execution flow and user feedback.
*/
export interface IHookCommandOutput {
/**
* If set, stops processing entirely after this hook.
* The message is shown to the user but not to the agent.
*/
readonly stopReason?: string;
/**
* Message shown to the user.
*/
readonly systemMessage?: string;
}
export const enum HookCommandResultKind {
Success = 1,
/** Blocking error - shown to model */
Error = 2,
/** Non-blocking error - shown to user only */
NonBlockingError = 3
}
/**
* Raw result from spawning a hook command.
* This is the low-level result before semantic processing.
*/
export interface IHookCommandResult {
readonly kind: HookCommandResultKind;
/**
* For success, this is stdout (parsed as JSON if valid, otherwise string).
* For errors, this is stderr.
*/
readonly result: string | object;
}
//#endregion

View File

@@ -1,465 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancellationToken } from '../../../../../base/common/cancellation.js';
import { Emitter, Event } from '../../../../../base/common/event.js';
import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
import { StopWatch } from '../../../../../base/common/stopwatch.js';
import { URI, isUriComponents } from '../../../../../base/common/uri.js';
import { localize } from '../../../../../nls.js';
import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';
import { ILogService } from '../../../../../platform/log/common/log.js';
import { Registry } from '../../../../../platform/registry/common/platform.js';
import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../../services/output/common/output.js';
import { HookTypeValue, IChatRequestHooks, IHookCommand } from '../promptSyntax/hookSchema.js';
import {
HookCommandResultKind,
IHookCommandInput,
IHookCommandResult,
} from './hooksCommandTypes.js';
import {
commonHookOutputValidator,
IHookResult,
} from './hooksTypes.js';
export const hooksOutputChannelId = 'hooksExecution';
const hooksOutputChannelLabel = localize('hooksExecutionChannel', "Hooks");
export interface IHooksExecutionOptions {
readonly input?: unknown;
readonly token?: CancellationToken;
}
export interface IHookExecutedEvent {
readonly hookType: HookTypeValue;
readonly sessionResource: URI;
readonly input: unknown;
readonly results: readonly IHookResult[];
readonly durationMs: number;
}
/**
* Event fired when a hook produces progress that should be shown to the user.
*/
export interface IHookProgressEvent {
readonly hookType: HookTypeValue;
readonly sessionResource: URI;
readonly stopReason?: string;
readonly systemMessage?: string;
}
/**
* Callback interface for hook execution proxies.
* MainThreadHooks implements this to forward calls to the extension host.
*/
export interface IHooksExecutionProxy {
runHookCommand(hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise<IHookCommandResult>;
}
export const IHooksExecutionService = createDecorator<IHooksExecutionService>('hooksExecutionService');
export interface IHooksExecutionService {
_serviceBrand: undefined;
/**
* Fires when a hook has finished executing.
*/
readonly onDidExecuteHook: Event<IHookExecutedEvent>;
/**
* Fires when a hook produces progress (warning or stop) that should be shown to the user.
*/
readonly onDidHookProgress: Event<IHookProgressEvent>;
/**
* Called by mainThreadHooks when extension host is ready
*/
setProxy(proxy: IHooksExecutionProxy): void;
/**
* Register hooks for a session. Returns a disposable that unregisters them.
*/
registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable;
/**
* Get hooks registered for a session.
*/
getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined;
/**
* Execute hooks of the given type for the given session
*/
executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise<IHookResult[]>;
}
/**
* Keys that should be redacted when logging hook input.
*/
const redactedInputKeys = ['toolArgs'];
export class HooksExecutionService extends Disposable implements IHooksExecutionService {
declare readonly _serviceBrand: undefined;
private readonly _onDidExecuteHook = this._register(new Emitter<IHookExecutedEvent>());
readonly onDidExecuteHook: Event<IHookExecutedEvent> = this._onDidExecuteHook.event;
private readonly _onDidHookProgress = this._register(new Emitter<IHookProgressEvent>());
readonly onDidHookProgress: Event<IHookProgressEvent> = this._onDidHookProgress.event;
private _proxy: IHooksExecutionProxy | undefined;
private readonly _sessionHooks = new Map<string, IChatRequestHooks>();
/** Stored transcript path per session (keyed by session URI string). */
private readonly _sessionTranscriptPaths = new Map<string, URI>();
private _channelRegistered = false;
private _requestCounter = 0;
constructor(
@ILogService private readonly _logService: ILogService,
@IOutputService private readonly _outputService: IOutputService,
) {
super();
}
setProxy(proxy: IHooksExecutionProxy): void {
this._proxy = proxy;
}
private _ensureOutputChannel(): void {
if (this._channelRegistered) {
return;
}
Registry.as<IOutputChannelRegistry>(Extensions.OutputChannels).registerChannel({
id: hooksOutputChannelId,
label: hooksOutputChannelLabel,
log: false
});
this._channelRegistered = true;
}
private _log(requestId: number, hookType: HookTypeValue, message: string): void {
this._ensureOutputChannel();
const channel = this._outputService.getChannel(hooksOutputChannelId);
if (channel) {
channel.append(`${new Date().toISOString()} [#${requestId}] [${hookType}] ${message}\n`);
}
}
private _redactForLogging(input: object): object {
const result: Record<string, unknown> = { ...input };
for (const key of redactedInputKeys) {
if (Object.hasOwn(result, key)) {
result[key] = '...';
}
}
return result;
}
/**
* JSON.stringify replacer that converts URI / UriComponents values to their string form.
*/
private readonly _uriReplacer = (_key: string, value: unknown): unknown => {
if (URI.isUri(value)) {
return value.fsPath;
}
if (isUriComponents(value)) {
return URI.revive(value).fsPath;
}
return value;
};
private async _runSingleHook(
requestId: number,
hookType: HookTypeValue,
hookCommand: IHookCommand,
sessionResource: URI,
callerInput: unknown,
transcriptPath: URI | undefined,
token: CancellationToken
): Promise<IHookResult> {
// Build the common hook input properties.
// URI values are kept as URI objects through the RPC boundary, and converted
// to filesystem paths on the extension host side during JSON serialization.
const commonInput: IHookCommandInput = {
timestamp: new Date().toISOString(),
cwd: hookCommand.cwd ?? URI.file(''),
sessionId: sessionResource.toString(),
hookEventName: hookType,
...(transcriptPath ? { transcript_path: transcriptPath } : undefined),
};
// Merge common properties with caller-specific input
const fullInput = !!callerInput && typeof callerInput === 'object'
? { ...commonInput, ...callerInput }
: commonInput;
const hookCommandJson = JSON.stringify(hookCommand, this._uriReplacer);
this._log(requestId, hookType, `Running: ${hookCommandJson}`);
const inputForLog = this._redactForLogging(fullInput);
this._log(requestId, hookType, `Input: ${JSON.stringify(inputForLog, this._uriReplacer)}`);
const sw = StopWatch.create();
try {
const commandResult = await this._proxy!.runHookCommand(hookCommand, fullInput, token);
const result = this._toInternalResult(commandResult);
this._logCommandResult(requestId, hookType, commandResult, Math.round(sw.elapsed()));
return result;
} catch (err) {
const errMessage = err instanceof Error ? err.message : String(err);
this._log(requestId, hookType, `Error in ${Math.round(sw.elapsed())}ms: ${errMessage}`);
// Proxy errors (e.g., process spawn failure) are treated as warnings
return {
resultKind: 'warning',
output: undefined,
warningMessage: errMessage,
};
}
}
private _toInternalResult(commandResult: IHookCommandResult): IHookResult {
switch (commandResult.kind) {
case HookCommandResultKind.Error: {
// Exit code 2 - stop processing with message shown to user (not model)
// Equivalent to continue=false with stopReason=stderr
const message = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);
return {
resultKind: 'error',
stopReason: message,
output: undefined,
};
}
case HookCommandResultKind.NonBlockingError: {
// Non-blocking error - shown to user only as warning
const errorMessage = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);
return {
resultKind: 'warning',
output: undefined,
warningMessage: errorMessage,
};
}
case HookCommandResultKind.Success: {
// For string results, no common fields to extract
if (typeof commandResult.result !== 'object') {
return {
resultKind: 'success',
output: commandResult.result,
};
}
// Extract and validate common fields
const validationResult = commonHookOutputValidator.validate(commandResult.result);
const commonFields = validationResult.error ? {} : validationResult.content;
// Extract only known hook-specific fields for output
const resultObj = commandResult.result as Record<string, unknown>;
const hookOutput = this._extractHookSpecificOutput(resultObj);
// Handle continue field: when false, stopReason is effective
// stopReason takes precedence if both are set
let stopReason = commonFields.stopReason;
if (commonFields.continue === false && !stopReason) {
stopReason = ''; // Empty string signals stop without a specific reason
}
return {
resultKind: 'success',
stopReason,
warningMessage: commonFields.systemMessage,
output: Object.keys(hookOutput).length > 0 ? hookOutput : undefined,
};
}
default: {
// Unexpected result kind - treat as warning
return {
resultKind: 'warning',
warningMessage: `Unexpected hook command result kind: ${(commandResult as IHookCommandResult).kind}`,
output: undefined,
};
}
}
}
/**
* Extract hook-specific output fields, excluding common fields.
*/
private _extractHookSpecificOutput(result: Record<string, unknown>): Record<string, unknown> {
const commonFields = new Set(['continue', 'stopReason', 'systemMessage']);
const output: Record<string, unknown> = {};
for (const [key, value] of Object.entries(result)) {
if (value !== undefined && !commonFields.has(key)) {
output[key] = value;
}
}
return output;
}
private _logCommandResult(requestId: number, hookType: HookTypeValue, result: IHookCommandResult, elapsed: number): void {
const resultKindStr = result.kind === HookCommandResultKind.Success ? 'Success'
: result.kind === HookCommandResultKind.NonBlockingError ? 'NonBlockingError'
: 'Error';
const resultStr = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
const hasOutput = resultStr.length > 0 && resultStr !== '{}' && resultStr !== '[]';
if (hasOutput) {
this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms`);
this._log(requestId, hookType, `Output: ${resultStr}`);
} else {
this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms, no output`);
}
}
/**
* Extract `transcript_path` from hook input if present.
* The caller (e.g. SessionStart) may include it as a URI in the input object.
*/
private _extractTranscriptPath(input: unknown): URI | undefined {
if (typeof input !== 'object' || input === null) {
return undefined;
}
const transcriptPath = (input as Record<string, unknown>)['transcriptPath'];
if (URI.isUri(transcriptPath)) {
return transcriptPath;
}
if (isUriComponents(transcriptPath)) {
return URI.revive(transcriptPath);
}
return undefined;
}
/**
* Emit a hook progress event to show warnings or stop reasons to the user.
*/
private _emitHookProgress(hookType: HookTypeValue, sessionResource: URI, stopReason?: string, systemMessage?: string): void {
this._onDidHookProgress.fire({
hookType,
sessionResource,
stopReason,
systemMessage,
});
}
/**
* Collect all warning messages from hook results and emit them as a single aggregated progress event.
* Uses numbered list formatting when there are multiple warnings.
*/
private _emitAggregatedWarnings(hookType: HookTypeValue, sessionResource: URI, results: readonly IHookResult[]): void {
const warnings = results
.filter(r => r.warningMessage !== undefined)
.map(r => r.warningMessage!);
if (warnings.length > 0) {
const message = warnings.length === 1
? warnings[0]
: warnings.map((w, i) => `${i + 1}. ${w}`).join('\n');
this._emitHookProgress(hookType, sessionResource, undefined, message);
}
}
registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable {
const key = sessionResource.toString();
this._sessionHooks.set(key, hooks);
return toDisposable(() => {
this._sessionHooks.delete(key);
this._sessionTranscriptPaths.delete(key);
});
}
getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined {
return this._sessionHooks.get(sessionResource.toString());
}
async executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise<IHookResult[]> {
const sw = StopWatch.create();
const results: IHookResult[] = [];
try {
if (!this._proxy) {
return results;
}
const sessionKey = sessionResource.toString();
// Extract and store transcript_path from input when present (e.g. SessionStart)
const inputTranscriptPath = this._extractTranscriptPath(options?.input);
if (inputTranscriptPath) {
this._sessionTranscriptPaths.set(sessionKey, inputTranscriptPath);
}
const hooks = this.getHooksForSession(sessionResource);
if (!hooks) {
return results;
}
const hookCommands = hooks[hookType];
if (!hookCommands || hookCommands.length === 0) {
return results;
}
const transcriptPath = this._sessionTranscriptPaths.get(sessionKey);
const requestId = this._requestCounter++;
const token = options?.token ?? CancellationToken.None;
this._logService.debug(`[HooksExecutionService] Executing ${hookCommands.length} hook(s) for type '${hookType}'`);
this._log(requestId, hookType, `Executing ${hookCommands.length} hook(s)`);
for (const hookCommand of hookCommands) {
const result = await this._runSingleHook(requestId, hookType, hookCommand, sessionResource, options?.input, transcriptPath, token);
results.push(result);
// If stopReason is set, stop processing remaining hooks
if (result.stopReason) {
this._log(requestId, hookType, `Stopping: ${result.stopReason}`);
break;
}
}
// Emit aggregated warnings for any hook results that had warning messages
this._emitAggregatedWarnings(hookType, sessionResource, results);
// If any hook set stopReason, emit progress so it's visible to the user
const stoppedResult = results.find(r => r.stopReason !== undefined);
if (stoppedResult?.stopReason) {
this._emitHookProgress(hookType, sessionResource, formatHookErrorMessage(stoppedResult.stopReason));
}
return results;
} finally {
this._onDidExecuteHook.fire({
hookType,
sessionResource,
input: options?.input,
results,
durationMs: Math.round(sw.elapsed()),
});
}
}
}
/**
* Error thrown when a hook requests the agent to abort processing.
* The message should be shown to the user.
*/
export class HookAbortError extends Error {
constructor(
public readonly hookType: string,
public readonly stopReason: string
) {
super(`Hook ${hookType} aborted: ${stopReason}`);
this.name = 'HookAbortError';
}
}
/**
* Formats a localized error message for a failed hook.
* @param errorMessage The error message from the hook
* @returns A localized error message string
*/
export function formatHookErrorMessage(errorMessage: string): string {
if (errorMessage) {
return localize('hookFatalErrorWithMessage', 'A hook prevented chat from continuing. Please check the Hooks output channel for more details. Error message: {0}', errorMessage);
}
return localize('hookFatalError', 'A hook prevented chat from continuing. Please check the Hooks output channel for more details.');
}

View File

@@ -1,56 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* Internal hook types - types used within VS Code's hooks execution service.
*
* "Internal" means these types are used by VS Code code only - they never cross the
* process boundary to external hook commands. They use camelCase for field names.
*
* External types (in hooksCommandTypes.ts) define the contract with spawned commands.
*/
import { vBoolean, vObj, vOptionalProp, vString } from '../../../../../base/common/validation.js';
//#region Common Hook Types
/**
* The kind of result from executing a hook command.
*/
export type HookResultKind = 'success' | 'error' | 'warning';
/**
* Semantic hook result with common fields extracted and defaults applied.
* This is what callers receive from executeHook.
*/
export interface IHookResult {
/**
* The kind of result from executing the hook.
*/
readonly resultKind: HookResultKind;
/**
* If set, the agent should stop processing entirely after this hook.
* The message is shown to the user but not to the agent.
*/
readonly stopReason?: string;
/**
* Warning message shown to the user.
* (Mapped from `systemMessage` in command output, or stderr for non-blocking errors.)
*/
readonly warningMessage?: string;
/**
* The hook's output (hook-specific fields only).
* For errors, this is the error message string.
*/
readonly output: unknown;
}
export const commonHookOutputValidator = vObj({
continue: vOptionalProp(vBoolean()),
stopReason: vOptionalProp(vString()),
systemMessage: vOptionalProp(vString()),
});
//#endregion

View File

@@ -47,7 +47,6 @@ import { IChatVariablesService } from '../../../common/attachments/chatVariables
import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js';
import { ILanguageModelsService } from '../../../common/languageModels.js';
import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js';
import { IHooksExecutionService } from '../../../common/hooks/hooksExecutionService.js';
import { NullLanguageModelsService } from '../../common/languageModels.js';
import { MockChatVariablesService } from '../../common/mockChatVariables.js';
import { MockPromptsService } from '../../common/promptSyntax/service/mockPromptsService.js';
@@ -90,9 +89,6 @@ suite('ChatEditingService', function () {
collection.set(IMcpService, new TestMcpService());
collection.set(IPromptsService, new MockPromptsService());
collection.set(ILanguageModelsService, new SyncDescriptor(NullLanguageModelsService));
collection.set(IHooksExecutionService, new class extends mock<IHooksExecutionService>() {
override registerHooks() { return Disposable.None; }
});
collection.set(IMultiDiffSourceResolverService, new class extends mock<IMultiDiffSourceResolverService>() {
override registerResolver(_resolver: IMultiDiffSourceResolver): IDisposable {
return Disposable.None;

View File

@@ -8,7 +8,6 @@ import { Barrier } from '../../../../../../base/common/async.js';
import { VSBuffer } from '../../../../../../base/common/buffer.js';
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
import { CancellationError, isCancellationError } from '../../../../../../base/common/errors.js';
import { Event } from '../../../../../../base/common/event.js';
import { URI } from '../../../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js';
@@ -34,10 +33,6 @@ import { ILanguageModelToolsConfirmationService } from '../../../common/tools/la
import { MockLanguageModelToolsConfirmationService } from '../../common/tools/mockLanguageModelToolsConfirmationService.js';
import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js';
import { ILanguageModelChatMetadata } from '../../../common/languageModels.js';
import { IHookResult } from '../../../common/hooks/hooksTypes.js';
import { IHooksExecutionService, IHooksExecutionOptions, IHooksExecutionProxy } from '../../../common/hooks/hooksExecutionService.js';
import { HookTypeValue, IChatRequestHooks } from '../../../common/promptSyntax/hookSchema.js';
import { IDisposable } from '../../../../../../base/common/lifecycle.js';
// --- Test helpers to reduce repetition and improve readability ---
@@ -65,19 +60,6 @@ class TestTelemetryService implements Partial<ITelemetryService> {
}
}
class MockHooksExecutionService implements IHooksExecutionService {
readonly _serviceBrand: undefined;
readonly onDidExecuteHook = Event.None;
readonly onDidHookProgress = Event.None;
setProxy(_proxy: IHooksExecutionProxy): void { }
registerHooks(_sessionResource: URI, _hooks: IChatRequestHooks): IDisposable { return { dispose: () => { } }; }
getHooksForSession(_sessionResource: URI): IChatRequestHooks | undefined { return undefined; }
executeHook(_hookType: HookTypeValue, _sessionResource: URI, _options?: IHooksExecutionOptions): Promise<IHookResult[]> {
return Promise.resolve([]);
}
}
function registerToolForTest(service: LanguageModelToolsService, store: any, id: string, impl: IToolImpl, data?: Partial<IToolData>) {
const toolData: IToolData = {
id,
@@ -136,7 +118,6 @@ interface TestToolsServiceOptions {
accessibilityService?: IAccessibilityService;
accessibilitySignalService?: Partial<IAccessibilitySignalService>;
telemetryService?: Partial<ITelemetryService>;
hooksExecutionService?: MockHooksExecutionService;
commandService?: Partial<ICommandService>;
/** Called after configurationService is created but before the service is instantiated */
configureServices?: (config: TestConfigurationService) => void;
@@ -161,7 +142,6 @@ function createTestToolsService(store: ReturnType<typeof ensureNoDisposablesAreL
const chatService = new MockChatService();
instaService.stub(IChatService, chatService);
instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService());
instaService.stub(IHooksExecutionService, options?.hooksExecutionService ?? new MockHooksExecutionService());
if (options?.accessibilityService) {
instaService.stub(IAccessibilityService, options.accessibilityService);
@@ -1732,7 +1712,6 @@ suite('LanguageModelToolsService', () => {
instaService1.stub(IAccessibilityService, testAccessibilityService1);
instaService1.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService);
instaService1.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService());
instaService1.stub(IHooksExecutionService, new MockHooksExecutionService());
const testService1 = store.add(instaService1.createInstance(LanguageModelToolsService));
const tool1 = registerToolForTest(testService1, store, 'soundOnlyTool', {
@@ -1774,7 +1753,6 @@ suite('LanguageModelToolsService', () => {
instaService2.stub(IAccessibilityService, testAccessibilityService2);
instaService2.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService);
instaService2.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService());
instaService2.stub(IHooksExecutionService, new MockHooksExecutionService());
const testService2 = store.add(instaService2.createInstance(LanguageModelToolsService));
const tool2 = registerToolForTest(testService2, store, 'autoScreenReaderTool', {
@@ -1817,7 +1795,6 @@ suite('LanguageModelToolsService', () => {
instaService3.stub(IAccessibilityService, testAccessibilityService3);
instaService3.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService);
instaService3.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService());
instaService3.stub(IHooksExecutionService, new MockHooksExecutionService());
const testService3 = store.add(instaService3.createInstance(LanguageModelToolsService));
const tool3 = registerToolForTest(testService3, store, 'offTool', {
@@ -2587,7 +2564,6 @@ suite('LanguageModelToolsService', () => {
}, store);
instaService.stub(IChatService, chatService);
instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService());
instaService.stub(IHooksExecutionService, new MockHooksExecutionService());
const testService = store.add(instaService.createInstance(LanguageModelToolsService));
const tool = registerToolForTest(testService, store, 'gitCommitTool', {
@@ -2626,7 +2602,6 @@ suite('LanguageModelToolsService', () => {
}, store);
instaService.stub(IChatService, chatService);
instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService());
instaService.stub(IHooksExecutionService, new MockHooksExecutionService());
const testService = store.add(instaService.createInstance(LanguageModelToolsService));
// Tool that was previously namespaced under extension but is now internal
@@ -2666,7 +2641,6 @@ suite('LanguageModelToolsService', () => {
}, store);
instaService.stub(IChatService, chatService);
instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService());
instaService.stub(IHooksExecutionService, new MockHooksExecutionService());
const testService = store.add(instaService.createInstance(LanguageModelToolsService));
// Tool that was previously namespaced under extension but is now internal
@@ -2709,7 +2683,6 @@ suite('LanguageModelToolsService', () => {
}, store);
instaService.stub(IChatService, chatService);
instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService());
instaService.stub(IHooksExecutionService, new MockHooksExecutionService());
const testService = store.add(instaService.createInstance(LanguageModelToolsService));
// Tool that was previously namespaced under extension but is now internal
@@ -3799,15 +3772,11 @@ suite('LanguageModelToolsService', () => {
});
suite('preToolUse hooks', () => {
let mockHooksService: MockHooksExecutionService;
let hookService: LanguageModelToolsService;
let hookChatService: MockChatService;
setup(() => {
mockHooksService = new MockHooksExecutionService();
const setup = createTestToolsService(store, {
hooksExecutionService: mockHooksService
});
const setup = createTestToolsService(store);
hookService = setup.service;
hookChatService = setup.chatService;
});
@@ -4045,9 +4014,7 @@ suite('LanguageModelToolsService', () => {
}
};
const mockHooks = new MockHooksExecutionService();
const setup = createTestToolsService(store, {
hooksExecutionService: mockHooks,
commandService: mockCommandService as ICommandService,
});
@@ -4101,9 +4068,7 @@ suite('LanguageModelToolsService', () => {
}
};
const mockHooks = new MockHooksExecutionService();
const setup = createTestToolsService(store, {
hooksExecutionService: mockHooks,
commandService: mockCommandService as ICommandService,
});

View File

@@ -1,402 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';
import { URI } from '../../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { NullLogService } from '../../../../../platform/log/common/log.js';
import { HookCommandResultKind, IHookCommandResult } from '../../common/hooks/hooksCommandTypes.js';
import { HooksExecutionService, IHooksExecutionProxy } from '../../common/hooks/hooksExecutionService.js';
import { HookType, IHookCommand } from '../../common/promptSyntax/hookSchema.js';
import { IOutputChannel, IOutputService } from '../../../../services/output/common/output.js';
function cmd(command: string): IHookCommand {
return { type: 'command', command, cwd: URI.file('/') };
}
function createMockOutputService(): IOutputService {
const mockChannel: Partial<IOutputChannel> = {
append: () => { },
};
return {
_serviceBrand: undefined,
getChannel: () => mockChannel as IOutputChannel,
} as unknown as IOutputService;
}
suite('HooksExecutionService', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();
let service: HooksExecutionService;
const sessionUri = URI.file('/test/session');
setup(() => {
service = store.add(new HooksExecutionService(new NullLogService(), createMockOutputService()));
});
suite('registerHooks', () => {
test('registers hooks for a session', () => {
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
store.add(service.registerHooks(sessionUri, hooks));
assert.strictEqual(service.getHooksForSession(sessionUri), hooks);
});
test('returns disposable that unregisters hooks', () => {
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
const disposable = service.registerHooks(sessionUri, hooks);
assert.strictEqual(service.getHooksForSession(sessionUri), hooks);
disposable.dispose();
assert.strictEqual(service.getHooksForSession(sessionUri), undefined);
});
test('different sessions have independent hooks', () => {
const session1 = URI.file('/test/session1');
const session2 = URI.file('/test/session2');
const hooks1 = { [HookType.PreToolUse]: [cmd('echo 1')] };
const hooks2 = { [HookType.PostToolUse]: [cmd('echo 2')] };
store.add(service.registerHooks(session1, hooks1));
store.add(service.registerHooks(session2, hooks2));
assert.strictEqual(service.getHooksForSession(session1), hooks1);
assert.strictEqual(service.getHooksForSession(session2), hooks2);
});
});
suite('getHooksForSession', () => {
test('returns undefined for unregistered session', () => {
assert.strictEqual(service.getHooksForSession(sessionUri), undefined);
});
});
suite('executeHook', () => {
test('returns empty array when no proxy set', async () => {
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
store.add(service.registerHooks(sessionUri, hooks));
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
assert.deepStrictEqual(results, []);
});
test('returns empty array when no hooks registered for session', async () => {
const proxy = createMockProxy();
service.setProxy(proxy);
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
assert.deepStrictEqual(results, []);
});
test('returns empty array when no hooks of requested type', async () => {
const proxy = createMockProxy();
service.setProxy(proxy);
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
store.add(service.registerHooks(sessionUri, hooks));
const results = await service.executeHook(HookType.PostToolUse, sessionUri);
assert.deepStrictEqual(results, []);
});
test('executes hook commands via proxy and returns semantic results', async () => {
const proxy = createMockProxy((cmd) => ({
kind: HookCommandResultKind.Success,
result: `executed: ${cmd.command}`
}));
service.setProxy(proxy);
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
store.add(service.registerHooks(sessionUri, hooks));
const results = await service.executeHook(HookType.PreToolUse, sessionUri, { input: 'test-input' });
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].resultKind, 'success');
assert.strictEqual(results[0].stopReason, undefined);
assert.strictEqual(results[0].output, 'executed: echo test');
});
test('executes multiple hook commands in order', async () => {
const executedCommands: string[] = [];
const proxy = createMockProxy((cmd) => {
executedCommands.push(cmd.command ?? '');
return { kind: HookCommandResultKind.Success, result: 'ok' };
});
service.setProxy(proxy);
const hooks = {
[HookType.PreToolUse]: [cmd('cmd1'), cmd('cmd2'), cmd('cmd3')]
};
store.add(service.registerHooks(sessionUri, hooks));
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
assert.strictEqual(results.length, 3);
assert.deepStrictEqual(executedCommands, ['cmd1', 'cmd2', 'cmd3']);
});
test('wraps proxy errors in warning result', async () => {
const proxy = createMockProxy(() => {
throw new Error('proxy failed');
});
service.setProxy(proxy);
const hooks = { [HookType.PreToolUse]: [cmd('fail')] };
store.add(service.registerHooks(sessionUri, hooks));
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
assert.strictEqual(results.length, 1);
// Proxy errors are now treated as warnings (non-blocking)
assert.strictEqual(results[0].resultKind, 'warning');
assert.strictEqual(results[0].warningMessage, 'proxy failed');
assert.strictEqual(results[0].output, undefined);
assert.strictEqual(results[0].stopReason, undefined);
});
test('passes cancellation token to proxy', async () => {
let receivedToken: CancellationToken | undefined;
const proxy = createMockProxy((_cmd, _input, token) => {
receivedToken = token;
return { kind: HookCommandResultKind.Success, result: 'ok' };
});
service.setProxy(proxy);
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
store.add(service.registerHooks(sessionUri, hooks));
const cts = store.add(new CancellationTokenSource());
await service.executeHook(HookType.PreToolUse, sessionUri, { token: cts.token });
assert.strictEqual(receivedToken, cts.token);
});
test('uses CancellationToken.None when no token provided', async () => {
let receivedToken: CancellationToken | undefined;
const proxy = createMockProxy((_cmd, _input, token) => {
receivedToken = token;
return { kind: HookCommandResultKind.Success, result: 'ok' };
});
service.setProxy(proxy);
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
store.add(service.registerHooks(sessionUri, hooks));
await service.executeHook(HookType.PreToolUse, sessionUri);
assert.strictEqual(receivedToken, CancellationToken.None);
});
test('extracts common fields from successful result', async () => {
const proxy = createMockProxy(() => ({
kind: HookCommandResultKind.Success,
result: {
stopReason: 'User requested stop',
systemMessage: 'Warning: hook triggered',
hookSpecificOutput: {
permissionDecision: 'allow'
}
}
}));
service.setProxy(proxy);
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
store.add(service.registerHooks(sessionUri, hooks));
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].resultKind, 'success');
assert.strictEqual(results[0].stopReason, 'User requested stop');
assert.strictEqual(results[0].warningMessage, 'Warning: hook triggered');
// Hook-specific fields are in output with wrapper
assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } });
});
test('handles continue false by setting stopReason', async () => {
const proxy = createMockProxy(() => ({
kind: HookCommandResultKind.Success,
result: {
continue: false,
systemMessage: 'User requested to stop'
}
}));
service.setProxy(proxy);
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
store.add(service.registerHooks(sessionUri, hooks));
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].resultKind, 'success');
// continue:false without explicit stopReason sets stopReason to empty string
assert.strictEqual(results[0].stopReason, '');
assert.strictEqual(results[0].warningMessage, 'User requested to stop');
});
test('stopReason takes precedence over continue false', async () => {
const proxy = createMockProxy(() => ({
kind: HookCommandResultKind.Success,
result: {
continue: false,
stopReason: 'Explicit stop reason'
}
}));
service.setProxy(proxy);
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
store.add(service.registerHooks(sessionUri, hooks));
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].resultKind, 'success');
assert.strictEqual(results[0].stopReason, 'Explicit stop reason');
});
test('uses defaults when no common fields present', async () => {
const proxy = createMockProxy(() => ({
kind: HookCommandResultKind.Success,
result: {
hookSpecificOutput: {
permissionDecision: 'allow'
}
}
}));
service.setProxy(proxy);
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
store.add(service.registerHooks(sessionUri, hooks));
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].stopReason, undefined);
assert.strictEqual(results[0].warningMessage, undefined);
assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } });
});
test('handles error results from command (exit code 2) as stop with stopReason', async () => {
const proxy = createMockProxy(() => ({
kind: HookCommandResultKind.Error,
result: 'command failed with error'
}));
service.setProxy(proxy);
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
store.add(service.registerHooks(sessionUri, hooks));
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
assert.strictEqual(results.length, 1);
// Exit code 2 produces error with stopReason
assert.strictEqual(results[0].resultKind, 'error');
assert.strictEqual(results[0].stopReason, 'command failed with error');
assert.strictEqual(results[0].output, undefined);
});
test('handles non-blocking error results from command', async () => {
const proxy = createMockProxy(() => ({
kind: HookCommandResultKind.NonBlockingError,
result: 'non-blocking warning message'
}));
service.setProxy(proxy);
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
store.add(service.registerHooks(sessionUri, hooks));
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].resultKind, 'warning');
assert.strictEqual(results[0].output, undefined);
assert.strictEqual(results[0].warningMessage, 'non-blocking warning message');
assert.strictEqual(results[0].stopReason, undefined);
});
test('handles non-blocking error with object result', async () => {
const proxy = createMockProxy(() => ({
kind: HookCommandResultKind.NonBlockingError,
result: { code: 'WARN_001', message: 'Something went wrong' }
}));
service.setProxy(proxy);
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
store.add(service.registerHooks(sessionUri, hooks));
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].resultKind, 'warning');
assert.strictEqual(results[0].output, undefined);
assert.strictEqual(results[0].warningMessage, '{"code":"WARN_001","message":"Something went wrong"}');
assert.strictEqual(results[0].stopReason, undefined);
});
test('passes through hook-specific output fields for non-preToolUse hooks', async () => {
// Stop hooks return different fields (decision, reason) than preToolUse hooks
const proxy = createMockProxy(() => ({
kind: HookCommandResultKind.Success,
result: {
decision: 'block',
reason: 'Please run the tests'
}
}));
service.setProxy(proxy);
const hooks = { [HookType.Stop]: [cmd('check-stop')] };
store.add(service.registerHooks(sessionUri, hooks));
const results = await service.executeHook(HookType.Stop, sessionUri);
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].resultKind, 'success');
// Hook-specific fields should be in output, not undefined
assert.deepStrictEqual(results[0].output, {
decision: 'block',
reason: 'Please run the tests'
});
});
test('passes input to proxy', async () => {
let receivedInput: unknown;
const proxy = createMockProxy((_cmd, input) => {
receivedInput = input;
return { kind: HookCommandResultKind.Success, result: 'ok' };
});
service.setProxy(proxy);
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
store.add(service.registerHooks(sessionUri, hooks));
const testInput = { foo: 'bar', nested: { value: 123 } };
await service.executeHook(HookType.PreToolUse, sessionUri, { input: testInput });
// Input includes caller properties merged with common hook properties
assert.ok(typeof receivedInput === 'object' && receivedInput !== null);
const input = receivedInput as Record<string, unknown>;
assert.strictEqual(input['foo'], 'bar');
assert.deepStrictEqual(input['nested'], { value: 123 });
// Common properties are also present
assert.strictEqual(typeof input['timestamp'], 'string');
assert.strictEqual(input['hookEventName'], HookType.PreToolUse);
});
});
function createMockProxy(handler?: (cmd: IHookCommand, input: unknown, token: CancellationToken) => IHookCommandResult): IHooksExecutionProxy {
return {
runHookCommand: async (hookCommand, input, token) => {
if (handler) {
return handler(hookCommand, input, token);
}
return { kind: HookCommandResultKind.Success, result: 'mock result' };
}
};
}
});

View File

@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// version: 3
// version: 5
declare module 'vscode' {
@@ -13,18 +13,33 @@ declare module 'vscode' {
export type ChatHookType = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'PreCompact' | 'SubagentStart' | 'SubagentStop' | 'Stop';
/**
* Options for executing a hook command.
* A resolved hook command ready for execution.
* The command has already been resolved for the current platform.
*/
export interface ChatHookExecutionOptions {
export interface ChatHookCommand {
/**
* Input data to pass to the hook via stdin (will be JSON-serialized).
* The shell command to execute, already resolved for the current platform.
*/
readonly input?: unknown;
readonly command: string;
/**
* The tool invocation token from the chat request context,
* used to associate the hook execution with the current chat session.
* Working directory for the command.
*/
readonly toolInvocationToken: ChatParticipantToolToken;
readonly cwd?: Uri;
/**
* Additional environment variables for the command.
*/
readonly env?: Record<string, string>;
/**
* Maximum execution time in seconds.
*/
readonly timeoutSec?: number;
}
/**
* Collected hooks for a chat request, organized by hook type.
*/
export interface ChatRequestHooks {
readonly [hookType: string]: readonly ChatHookCommand[];
}
/**
@@ -60,20 +75,15 @@ declare module 'vscode' {
readonly output: unknown;
}
export namespace chat {
export interface ChatRequest {
/**
* Execute all hooks of the specified type for the current chat session.
* Hooks are configured in hooks .json files in the workspace.
*
* @param hookType The type of hook to execute.
* @param options Hook execution options including the input data.
* @param token Optional cancellation token.
* @returns A promise that resolves to an array of hook execution results.
* Resolved hook commands for this request, organized by hook type.
* The commands have already been resolved for the current platform.
* Only present when hooks are enabled.
*/
export function executeHook(hookType: ChatHookType, options: ChatHookExecutionOptions, token?: CancellationToken): Thenable<ChatHookResult[]>;
readonly hooks?: ChatRequestHooks;
}
/**
* A progress part representing the execution result of a hook.
* Hooks are user-configured scripts that run at specific points during chat processing.