Skip to content

Commit 59be36b

Browse files
digitaraldCopilot
andauthored
agentHost: fix PostToolUse hook additionalContext not injected into same-turn request (#311984)
Fix PostToolUse hook additionalContext injection into same-turn model request onPostToolUse was discarding the output of executeHookCommand instead of parsing and returning it, unlike onPreToolUse which correctly returned the parsed result. This caused additionalContext from PostToolUse hooks to only appear in subsequent turns via history replay, not the same turn. Also extracted a runHookCommands() helper to eliminate the copy-paste logic between onPreToolUse and onPostToolUse. Fixes #311138 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 392ff04 commit 59be36b

2 files changed

Lines changed: 142 additions & 32 deletions

File tree

src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,37 @@ function executeHookCommand(hook: IParsedHookCommand, stdin?: string): Promise<s
171171
});
172172
}
173173

174+
/**
175+
* Runs a list of hook commands sequentially, passing `input` as JSON stdin.
176+
* Returns the parsed output of the first command that emits a valid JSON object,
177+
* or `undefined` if no command produces parseable JSON output.
178+
* Command failures are swallowed — hooks are non-fatal.
179+
*/
180+
async function runHookCommands(commands: readonly IParsedHookCommand[] | undefined, input: unknown): Promise<object | undefined> {
181+
if (!commands) {
182+
return undefined;
183+
}
184+
const stdin = JSON.stringify(input);
185+
for (const cmd of commands) {
186+
try {
187+
const output = await executeHookCommand(cmd, stdin);
188+
if (output.trim()) {
189+
try {
190+
const parsed = JSON.parse(output);
191+
if (parsed && typeof parsed === 'object') {
192+
return parsed;
193+
}
194+
} catch {
195+
// Non-JSON output is fine — no modification
196+
}
197+
}
198+
} catch {
199+
// Hook failures are non-fatal
200+
}
201+
}
202+
return undefined;
203+
}
204+
174205
/**
175206
* Mapping from canonical hook type identifiers to SDK SessionHooks handler keys.
176207
*/
@@ -218,26 +249,7 @@ export function toSdkHooks(
218249
if (preToolCommands?.length || editTrackingHooks) {
219250
hooks.onPreToolUse = async (input: { toolName: string; toolArgs: unknown }) => {
220251
await editTrackingHooks?.onPreToolUse(input);
221-
if (preToolCommands) {
222-
const stdin = JSON.stringify(input);
223-
for (const cmd of preToolCommands) {
224-
try {
225-
const output = await executeHookCommand(cmd, stdin);
226-
if (output.trim()) {
227-
try {
228-
const parsed = JSON.parse(output);
229-
if (parsed && typeof parsed === 'object') {
230-
return parsed;
231-
}
232-
} catch {
233-
// Non-JSON output is fine — no modification
234-
}
235-
}
236-
} catch {
237-
// Hook failures are non-fatal
238-
}
239-
}
240-
}
252+
return runHookCommands(preToolCommands, input);
241253
};
242254
}
243255

@@ -246,16 +258,7 @@ export function toSdkHooks(
246258
if (postToolCommands?.length || editTrackingHooks) {
247259
hooks.onPostToolUse = async (input: { toolName: string; toolArgs: unknown }) => {
248260
await editTrackingHooks?.onPostToolUse(input);
249-
if (postToolCommands) {
250-
const stdin = JSON.stringify(input);
251-
for (const cmd of postToolCommands) {
252-
try {
253-
await executeHookCommand(cmd, stdin);
254-
} catch {
255-
// Hook failures are non-fatal
256-
}
257-
}
258-
}
261+
return runHookCommands(postToolCommands, input);
259262
};
260263
}
261264

src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import assert from 'assert';
7+
import { writeFileSync, unlinkSync } from 'fs';
8+
import { fileURLToPath } from 'url';
79
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
810
import { DisposableStore } from '../../../../base/common/lifecycle.js';
911
import { Schemas } from '../../../../base/common/network.js';
@@ -13,8 +15,8 @@ import { FileService } from '../../../files/common/fileService.js';
1315
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
1416
import { NullLogService } from '../../../log/common/log.js';
1517
import { 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

1921
suite('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

Comments
 (0)