44 *--------------------------------------------------------------------------------------------*/
55
66import assert from 'assert' ;
7+ import { writeFileSync , unlinkSync } from 'fs' ;
8+ import { fileURLToPath } from 'url' ;
79import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js' ;
810import { DisposableStore } from '../../../../base/common/lifecycle.js' ;
911import { Schemas } from '../../../../base/common/network.js' ;
@@ -13,8 +15,8 @@ import { FileService } from '../../../files/common/fileService.js';
1315import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js' ;
1416import { NullLogService } from '../../../log/common/log.js' ;
1517import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js' ;
16- import { toSdkMcpServers , toSdkCustomAgents , toSdkSkillDirectories , parsedPluginsEqual } from '../../node/copilot/copilotPluginConverters.js' ;
17- import type { IMcpServerDefinition , INamedPluginResource , IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js' ;
18+ import { toSdkMcpServers , toSdkCustomAgents , toSdkSkillDirectories , parsedPluginsEqual , toSdkHooks } from '../../node/copilot/copilotPluginConverters.js' ;
19+ import type { IMcpServerDefinition , INamedPluginResource , IParsedHookGroup , IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js' ;
1820
1921suite ( 'copilotPluginConverters' , ( ) => {
2022
@@ -187,6 +189,111 @@ suite('copilotPluginConverters', () => {
187189 } ) ;
188190 } ) ;
189191
192+ // ---- toSdkHooks -------------------------------------------------------
193+
194+ suite ( 'toSdkHooks' , ( ) => {
195+
196+ function makeHookGroup ( type : string , command : string ) : IParsedHookGroup {
197+ return {
198+ type,
199+ commands : [ { command } ] ,
200+ uri : URI . file ( '/plugin/hooks.json' ) ,
201+ originalId : type ,
202+ } ;
203+ }
204+
205+ /**
206+ * Writes a temp JS script that outputs JSON to stdout and returns
207+ * a `node <path>` command. Works on both bash (/bin/sh -c) and
208+ * cmd.exe without any shell-quoting issues.
209+ * The script is written alongside the compiled test file which is
210+ * guaranteed to exist, be writable, and have no spaces in CI.
211+ */
212+ function echoJsonCmd ( value : object ) : { command : string ; cleanup : ( ) => void } {
213+ const json = JSON . stringify ( value ) ;
214+ // fileURLToPath(new URL('.', import.meta.url)) is the Node ESM equivalent
215+ // of __dirname and works on Node 12+, unlike import.meta.dirname (Node 21.2+).
216+ const dir = fileURLToPath ( new URL ( '.' , import . meta. url ) ) . replace ( / [ \\ / ] $ / , '' ) ;
217+ const filePath = `${ dir } /vscode-test-hook-${ Date . now ( ) } .js` ;
218+ writeFileSync ( filePath , `process.stdout.write(${ JSON . stringify ( json ) } );\n` ) ;
219+ // Do NOT quote the path: cmd.exe /c "node path" strips the outer quotes,
220+ // leaving "node path" without inner quoting which cmd.exe handles cleanly.
221+ const command = `node ${ filePath } ` ;
222+ return { command, cleanup : ( ) => { try { unlinkSync ( filePath ) ; } catch { /* ignore */ } } } ;
223+ }
224+
225+ test ( 'onPostToolUse returns parsed JSON output as hook result' , async ( ) => {
226+ const expectedOutput = { additionalContext : 'Before presenting the plan, run review-plan skill' } ;
227+ const { command, cleanup } = echoJsonCmd ( expectedOutput ) ;
228+ try {
229+ const hookGroup = makeHookGroup ( 'PostToolUse' , command ) ;
230+ const hooks = toSdkHooks ( [ hookGroup ] ) ;
231+ const toolResult = { textResultForLlm : 'ok' , resultType : 'success' as const } ;
232+ const result = await hooks . onPostToolUse ! ( { toolName : 'memory' , toolArgs : { } , toolResult, timestamp : 0 , cwd : '/' } , { sessionId : 'test' } ) ;
233+ assert . deepStrictEqual ( result , expectedOutput ) ;
234+ } finally {
235+ cleanup ( ) ;
236+ }
237+ } ) ;
238+
239+ test ( 'onPostToolUse returns undefined when output is non-JSON' , async ( ) => {
240+ // Use a script file so there are no cmd.exe quoting issues on Windows.
241+ const dir = fileURLToPath ( new URL ( '.' , import . meta. url ) ) . replace ( / [ \\ / ] $ / , '' ) ;
242+ const filePath = `${ dir } /vscode-test-hook-nonjson-${ Date . now ( ) } .js` ;
243+ writeFileSync ( filePath , `process.stdout.write('not-json');\n` ) ;
244+ try {
245+ const hookGroup = makeHookGroup ( 'PostToolUse' , `node ${ filePath } ` ) ;
246+ const hooks = toSdkHooks ( [ hookGroup ] ) ;
247+ const toolResult = { textResultForLlm : 'ok' , resultType : 'success' as const } ;
248+ const result = await hooks . onPostToolUse ! ( { toolName : 'memory' , toolArgs : { } , toolResult, timestamp : 0 , cwd : '/' } , { sessionId : 'test' } ) ;
249+ assert . strictEqual ( result , undefined ) ;
250+ } finally {
251+ try { unlinkSync ( filePath ) ; } catch { /* ignore */ }
252+ }
253+ } ) ;
254+
255+ test ( 'onPostToolUse returns undefined when command fails' , async ( ) => {
256+ const dir = fileURLToPath ( new URL ( '.' , import . meta. url ) ) . replace ( / [ \\ / ] $ / , '' ) ;
257+ const filePath = `${ dir } /vscode-test-hook-fail-${ Date . now ( ) } .js` ;
258+ writeFileSync ( filePath , `process.exit(1);\n` ) ;
259+ try {
260+ const hookGroup = makeHookGroup ( 'PostToolUse' , `node ${ filePath } ` ) ;
261+ const hooks = toSdkHooks ( [ hookGroup ] ) ;
262+ const toolResult = { textResultForLlm : 'ok' , resultType : 'success' as const } ;
263+ const result = await hooks . onPostToolUse ! ( { toolName : 'memory' , toolArgs : { } , toolResult, timestamp : 0 , cwd : '/' } , { sessionId : 'test' } ) ;
264+ assert . strictEqual ( result , undefined ) ;
265+ } finally {
266+ try { unlinkSync ( filePath ) ; } catch { /* ignore */ }
267+ }
268+ } ) ;
269+
270+ test ( 'onPostToolUse returns undefined when no commands' , async ( ) => {
271+ const hooks = toSdkHooks ( [ ] ) ;
272+ assert . strictEqual ( hooks . onPostToolUse , undefined ) ;
273+ } ) ;
274+
275+ test ( 'onPostToolUse calls editTrackingHooks and returns command output' , async ( ) => {
276+ const expectedOutput = { additionalContext : 'context from hook' } ;
277+ const { command, cleanup } = echoJsonCmd ( expectedOutput ) ;
278+ try {
279+ const hookGroup = makeHookGroup ( 'PostToolUse' , command ) ;
280+ let trackingInput : unknown ;
281+ const editTrackingHooks = {
282+ onPreToolUse : async ( ) => { } ,
283+ onPostToolUse : async ( input : unknown ) => { trackingInput = input ; } ,
284+ } ;
285+ const hooks = toSdkHooks ( [ hookGroup ] , editTrackingHooks ) ;
286+ const toolResult = { textResultForLlm : 'ok' , resultType : 'success' as const } ;
287+ const callInput = { toolName : 'memory' , toolArgs : { } , toolResult, timestamp : 0 , cwd : '/' } ;
288+ const result = await hooks . onPostToolUse ! ( callInput , { sessionId : 'test' } ) ;
289+ assert . deepStrictEqual ( result , expectedOutput ) ;
290+ assert . deepStrictEqual ( trackingInput , callInput ) ;
291+ } finally {
292+ cleanup ( ) ;
293+ }
294+ } ) ;
295+ } ) ;
296+
190297 // ---- parsedPluginsEqual ---------------------------------------------
191298
192299 suite ( 'parsedPluginsEqual' , ( ) => {
0 commit comments