Skip to content

Commit 30b8cc2

Browse files
committed
fix(tool-search): preserve context tools with tests
1 parent 4e40541 commit 30b8cc2

2 files changed

Lines changed: 105 additions & 8 deletions

File tree

extensions/copilot/src/extension/tools/node/test/toolSearchTool.spec.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { IToolDeferralService } from '../../../../platform/networking/common/too
1010
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
1111
import { createExtensionUnitTestingServices } from '../../../test/node/services';
1212
import { TestChatRequest } from '../../../test/node/testHelpers';
13+
import { ChatVariablesCollection } from '../../../prompt/common/chatVariablesCollection';
14+
import { IBuildPromptContext } from '../../../prompt/common/intents';
1315
import { IToolsService } from '../../common/toolsService';
1416
import { ToolSearchTool } from '../toolSearchTool';
1517
import { TestToolsService } from './testToolsService';
@@ -24,6 +26,19 @@ function makeToolInfo(name: string): vscode.LanguageModelToolInformation {
2426
} as vscode.LanguageModelToolInformation;
2527
}
2628

29+
function makePromptContext(availableTools?: readonly vscode.LanguageModelToolInformation[]): IBuildPromptContext {
30+
return {
31+
query: 'test query',
32+
history: [],
33+
chatVariables: new ChatVariablesCollection([]),
34+
tools: availableTools ? {
35+
toolReferences: [],
36+
toolInvocationToken: undefined as never,
37+
availableTools,
38+
} : undefined,
39+
};
40+
}
41+
2742
suite('ToolSearchTool', () => {
2843
test('searches only deferred tools enabled for the active request', async () => {
2944
const searchedToolNames: string[][] = [];
@@ -157,6 +172,77 @@ suite('ToolSearchTool', () => {
157172
expect(searchedToolNames).toEqual([['activate_vs_code_interaction']]);
158173
});
159174

175+
test('preserves request-scoped deferred tools after resolved input is shallow-cloned', async () => {
176+
const searchedToolNames: string[][] = [];
177+
const nonDeferred = new Set(['read_file']);
178+
const tool = new ToolSearchTool(
179+
{
180+
searchToolsByQuery: async (_query: string, tools: readonly vscode.LanguageModelToolInformation[]) => {
181+
searchedToolNames.push(tools.map(candidate => candidate.name));
182+
return tools.map(candidate => candidate.name);
183+
},
184+
} as any,
185+
{
186+
isNonDeferredTool: (name: string) => nonDeferred.has(name),
187+
} as any,
188+
{ trace() { } } as any,
189+
);
190+
191+
const resolvedInput = await tool.resolveInput(
192+
{ query: 'vscode interaction tools' },
193+
makePromptContext([
194+
makeToolInfo('read_file'),
195+
makeToolInfo('activate_vs_code_interaction'),
196+
]),
197+
0,
198+
);
199+
200+
const clonedResolvedInput = { ...resolvedInput };
201+
202+
await tool.invoke(
203+
{ input: clonedResolvedInput } as vscode.LanguageModelToolInvocationOptions<{ query: string }>,
204+
{ isCancellationRequested: false } as vscode.CancellationToken,
205+
);
206+
207+
expect(searchedToolNames).toEqual([['activate_vs_code_interaction']]);
208+
expect((tool as any)._requestScopedDeferredToolsContexts.size).toBe(0);
209+
});
210+
211+
test('preserves request-scoped deferred tools after resolved input is JSON-round-tripped', async () => {
212+
const searchedToolNames: string[][] = [];
213+
const nonDeferred = new Set(['read_file']);
214+
const tool = new ToolSearchTool(
215+
{
216+
searchToolsByQuery: async (_query: string, tools: readonly vscode.LanguageModelToolInformation[]) => {
217+
searchedToolNames.push(tools.map(candidate => candidate.name));
218+
return tools.map(candidate => candidate.name);
219+
},
220+
} as any,
221+
{
222+
isNonDeferredTool: (name: string) => nonDeferred.has(name),
223+
} as any,
224+
{ trace() { } } as any,
225+
);
226+
227+
const resolvedInput = await tool.resolveInput(
228+
{ query: 'vscode interaction tools' },
229+
makePromptContext([
230+
makeToolInfo('read_file'),
231+
makeToolInfo('activate_vs_code_interaction'),
232+
]),
233+
0,
234+
);
235+
236+
const jsonRoundTrippedInput = JSON.parse(JSON.stringify(resolvedInput));
237+
238+
await tool.invoke(
239+
{ input: jsonRoundTrippedInput } as vscode.LanguageModelToolInvocationOptions<{ query: string }>,
240+
{ isCancellationRequested: false } as vscode.CancellationToken,
241+
);
242+
243+
expect(searchedToolNames).toEqual([['activate_vs_code_interaction']]);
244+
});
245+
160246
test('fails explicitly when invoke runs without request-scoped resolveInput context', async () => {
161247
const nonDeferred = new Set(['read_file']);
162248
const tool = new ToolSearchTool(

extensions/copilot/src/extension/tools/node/toolSearchTool.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,17 @@ export interface IToolSearchParams {
2020
}
2121

2222
const DEFAULT_SEARCH_LIMIT = 5;
23-
const requestScopedDeferredToolsKey = Symbol('requestScopedDeferredTools');
23+
const requestScopedDeferredToolsContextIdKey = '__toolSearchRequestContextId';
24+
25+
let nextRequestScopedDeferredToolsContextId = 0;
2426

2527
type ResolvedToolSearchParams = IToolSearchParams & {
26-
[requestScopedDeferredToolsKey]?: readonly vscode.LanguageModelToolInformation[];
28+
[requestScopedDeferredToolsContextIdKey]?: string;
2729
};
2830

2931
export class ToolSearchTool implements ICopilotModelSpecificTool<IToolSearchParams> {
32+
private readonly _requestScopedDeferredToolsContexts = new Map<string, readonly vscode.LanguageModelToolInformation[]>();
33+
3034
constructor(
3135
@IToolEmbeddingsComputer private readonly _toolEmbeddingsComputer: IToolEmbeddingsComputer,
3236
@IToolDeferralService private readonly _toolDeferralService: IToolDeferralService,
@@ -42,7 +46,13 @@ export class ToolSearchTool implements ICopilotModelSpecificTool<IToolSearchPara
4246
]);
4347
}
4448

45-
const requestScopedDeferredTools = (options.input as ResolvedToolSearchParams)[requestScopedDeferredToolsKey];
49+
const requestScopedDeferredToolsContextId = (options.input as ResolvedToolSearchParams)[requestScopedDeferredToolsContextIdKey];
50+
const requestScopedDeferredTools = requestScopedDeferredToolsContextId
51+
? this._requestScopedDeferredToolsContexts.get(requestScopedDeferredToolsContextId)
52+
: undefined;
53+
if (requestScopedDeferredToolsContextId && requestScopedDeferredTools) {
54+
this._requestScopedDeferredToolsContexts.delete(requestScopedDeferredToolsContextId);
55+
}
4656

4757
if (!requestScopedDeferredTools) {
4858
throw new Error('ToolSearchTool: request-scoped deferred tools are unavailable. Ensure resolveInput is called before invoke.');
@@ -67,11 +77,12 @@ export class ToolSearchTool implements ICopilotModelSpecificTool<IToolSearchPara
6777

6878
async resolveInput(input: IToolSearchParams, promptContext: IBuildPromptContext, _mode: CopilotToolMode): Promise<IToolSearchParams> {
6979
const manifest = createRequestToolManifest(promptContext.tools?.availableTools ?? [], this._toolDeferralService);
70-
const resolvedInput: ResolvedToolSearchParams = { ...input };
71-
Object.defineProperty(resolvedInput, requestScopedDeferredToolsKey, {
72-
value: Object.freeze([...manifest.deferredTools]),
73-
enumerable: false,
74-
});
80+
const requestScopedDeferredToolsContextId = `tool-search-${nextRequestScopedDeferredToolsContextId++}`;
81+
this._requestScopedDeferredToolsContexts.set(requestScopedDeferredToolsContextId, Object.freeze([...manifest.deferredTools]));
82+
const resolvedInput: ResolvedToolSearchParams = {
83+
...input,
84+
[requestScopedDeferredToolsContextIdKey]: requestScopedDeferredToolsContextId,
85+
};
7586
return resolvedInput;
7687
}
7788
}

0 commit comments

Comments
 (0)