mirror of
https://github.com/microsoft/vscode.git
synced 2026-02-15 07:28:05 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -98,7 +98,6 @@ import './mainThreadChatOutputRenderer.js';
|
||||
import './mainThreadChatSessions.js';
|
||||
import './mainThreadDataChannels.js';
|
||||
import './mainThreadMeteredConnection.js';
|
||||
import './mainThreadHooks.js';
|
||||
|
||||
export class ExtensionPoints implements IWorkbenchContribution {
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
46
src/vscode-dts/vscode.proposed.chatHooks.d.ts
vendored
46
src/vscode-dts/vscode.proposed.chatHooks.d.ts
vendored
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user