Path: blob/main/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Raw } from '@vscode/prompt-tsx';6import { beforeEach, describe, expect, it } from 'vitest';7import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';8import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';9import { ChatLocation } from '../../../chat/common/commonTypes';10import { ConfigKey, IConfigurationService } from '../../../configuration/common/configurationService';11import { InMemoryConfigurationService } from '../../../configuration/test/common/inMemoryConfigurationService';12import { IResponseDelta, OpenAiFunctionTool } from '../../../networking/common/fetch';13import { IChatEndpoint, ICreateEndpointBodyOptions } from '../../../networking/common/networking';14import { IToolDeferralService } from '../../../networking/common/toolDeferralService';15import { TelemetryData } from '../../../telemetry/common/telemetryData';16import { SpyingTelemetryService } from '../../../telemetry/node/spyingTelemetryService';17import { createPlatformServices } from '../../../test/node/services';18import { createResponsesRequestBody, OpenAIResponsesProcessor } from '../responsesApi';1920function createMockEndpoint(model: string): IChatEndpoint {21return {22model,23family: model,24modelProvider: 'openai',25supportsToolCalls: true,26supportsVision: false,27supportsPrediction: false,28showInModelPicker: true,29isFallback: false,30maxOutputTokens: 4096,31modelMaxPromptTokens: 128000,32urlOrRequestMetadata: 'https://test',33name: model,34version: '1',35tokenizer: 'cl100k_base' as any,36acquireTokenizer: () => { throw new Error('Not implemented'); },37processResponseFromChatEndpoint: () => { throw new Error('Not implemented'); },38makeChatRequest: () => { throw new Error('Not implemented'); },39makeChatRequest2: () => { throw new Error('Not implemented'); },40createRequestBody: () => { throw new Error('Not implemented'); },41cloneWithTokenOverride() { return this; },42} as unknown as IChatEndpoint;43}4445function createMockOptions(overrides: Partial<ICreateEndpointBodyOptions> = {}): ICreateEndpointBodyOptions {46return {47debugName: 'test',48messages: [{ role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] }],49location: ChatLocation.Agent,50finishedCb: undefined,51requestId: 'test-req-1',52postOptions: { max_tokens: 4096 },53requestOptions: {54tools: [55{ type: 'function', function: { name: 'read_file', description: 'Read a file', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } } },56{ type: 'function', function: { name: 'grep_search', description: 'Search for text', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } },57{ type: 'function', function: { name: 'some_mcp_tool', description: 'An MCP tool', parameters: { type: 'object', properties: { input: { type: 'string' } }, required: ['input'] } } },58{ type: 'function', function: { name: 'another_deferred_tool', description: 'Another tool', parameters: { type: 'object', properties: {} } } },59{ type: 'function', function: { name: 'tool_search', description: 'Search tools', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } },60]61},62...overrides,63} as ICreateEndpointBodyOptions;64}6566function createFunctionTool(name: string, description: string, properties: Record<string, object>, required: string[] = []): OpenAiFunctionTool {67return {68type: 'function',69function: {70name,71description,72parameters: { type: 'object', properties, ...(required.length ? { required } : {}) }73}74};75}7677describe('createResponsesRequestBody tools', () => {78let disposables: DisposableStore;79let services: ReturnType<typeof createPlatformServices>;80let accessor: ReturnType<ReturnType<typeof createPlatformServices>['createTestingAccessor']>;8182beforeEach(() => {83disposables = new DisposableStore();84services = createPlatformServices(disposables);85const coreNonDeferred = new Set(['read_file', 'list_dir', 'grep_search', 'semantic_search', 'file_search',86'replace_string_in_file', 'create_file', 'run_in_terminal', 'get_terminal_output',87'get_errors', 'manage_todo_list', 'runSubagent', 'search_subagent', 'execution_subagent',88'runTests', 'tool_search', 'view_image', 'fetch_webpage']);89services.define(IToolDeferralService, { _serviceBrand: undefined, isNonDeferredTool: (name: string) => coreNonDeferred.has(name) });90accessor = services.createTestingAccessor();91});9293function createToolSearchScenario(messages: Raw.ChatMessage[]) {94const endpoint = createMockEndpoint('gpt-5.4');95const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService;96configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, true);9798const options = createMockOptions({99messages,100requestOptions: {101tools: [102createFunctionTool('file_search', 'Find files', { query: { type: 'string' } }, ['query']),103createFunctionTool('read_file', 'Read a file', { path: { type: 'string' } }, ['path']),104createFunctionTool('some_mcp_tool', 'An MCP tool', { input: { type: 'string' } }, ['input']),105createFunctionTool('tool_search', 'Search tools', { query: { type: 'string' } }, ['query']),106]107}108});109110return accessor.get(IInstantiationService).invokeFunction(111createResponsesRequestBody, options, endpoint.model, endpoint112);113}114115it('passes tools through without defer_loading when tool search disabled', () => {116const endpoint = createMockEndpoint('gpt-5.4');117const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService;118configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, false);119120const body = accessor.get(IInstantiationService).invokeFunction(121createResponsesRequestBody, createMockOptions(), endpoint.model, endpoint122);123124const tools = body.tools as any[];125expect(tools).toBeDefined();126expect(tools.find(t => t.type === 'tool_search')).toBeUndefined();127expect(tools.every(t => !t.defer_loading)).toBe(true);128});129130it('adds client tool_search and defer_loading when enabled', () => {131const endpoint = createMockEndpoint('gpt-5.4');132const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService;133configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, true);134135const body = accessor.get(IInstantiationService).invokeFunction(136createResponsesRequestBody, createMockOptions(), endpoint.model, endpoint137);138139const tools = body.tools as any[];140expect(tools).toBeDefined();141142// Should have client-executed tool_search143const toolSearchTool = tools.find(t => t.type === 'tool_search');144expect(toolSearchTool).toBeDefined();145expect(toolSearchTool.execution).toBe('client');146147// Non-deferred tools should be present without defer_loading148expect(tools.find(t => t.name === 'read_file')?.defer_loading).toBeUndefined();149expect(tools.find(t => t.name === 'grep_search')?.defer_loading).toBeUndefined();150151// Deferred tools should NOT be in the request (client-executed mode excludes them entirely)152expect(tools.find(t => t.name === 'some_mcp_tool')).toBeUndefined();153expect(tools.find(t => t.name === 'another_deferred_tool')).toBeUndefined();154});155156it('does not defer tools for unsupported models', () => {157const endpoint = createMockEndpoint('gpt-4o');158const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService;159configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, true);160161const body = accessor.get(IInstantiationService).invokeFunction(162createResponsesRequestBody, createMockOptions(), endpoint.model, endpoint163);164165const tools = body.tools as any[];166expect(tools.find(t => t.type === 'tool_search')).toBeUndefined();167expect(tools.every(t => !t.defer_loading)).toBe(true);168});169170it('does not defer tools for non-Agent locations', () => {171const endpoint = createMockEndpoint('gpt-5.4');172const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService;173configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, true);174175const options = createMockOptions({ location: ChatLocation.Panel });176const body = accessor.get(IInstantiationService).invokeFunction(177createResponsesRequestBody, options, endpoint.model, endpoint178);179180const tools = body.tools as any[];181expect(tools.find(t => t.type === 'tool_search')).toBeUndefined();182expect(tools.every(t => !t.defer_loading)).toBe(true);183});184185it('does not defer tools when tool_search is not in the request tool list', () => {186// Repro for https://github.com/microsoft/vscode/issues/311946: a custom agent with187// `tools: ['my-mcp-server/*']` filters out tool_search. Without this gate, every188// MCP tool would be marked deferred and stripped from the request, leaving the189// agent with nothing to call.190const endpoint = createMockEndpoint('gpt-5.4');191const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService;192configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, true);193194const options = createMockOptions({195requestOptions: {196tools: [197{ type: 'function', function: { name: 'some_mcp_tool', description: 'An MCP tool', parameters: { type: 'object', properties: {} } } },198{ type: 'function', function: { name: 'another_mcp_tool', description: 'Another MCP tool', parameters: { type: 'object', properties: {} } } },199]200}201});202const body = accessor.get(IInstantiationService).invokeFunction(203createResponsesRequestBody, options, endpoint.model, endpoint204);205206const tools = body.tools as any[];207// No client tool_search should be added.208expect(tools.find(t => t.type === 'tool_search')).toBeUndefined();209// All user-listed tools should be sent to the model, not stripped.210expect(tools.find(t => t.name === 'some_mcp_tool')).toBeDefined();211expect(tools.find(t => t.name === 'another_mcp_tool')).toBeDefined();212});213214it('always filters tool_search function tool from tools array', () => {215const endpoint = createMockEndpoint('gpt-5.4');216const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService;217configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, false);218219const options = createMockOptions({220requestOptions: {221tools: [222{ type: 'function', function: { name: 'read_file', description: 'Read a file', parameters: { type: 'object', properties: {} } } },223{ type: 'function', function: { name: 'tool_search', description: 'Search tools', parameters: { type: 'object', properties: {} } } },224]225}226});227const body = accessor.get(IInstantiationService).invokeFunction(228createResponsesRequestBody, options, endpoint.model, endpoint229);230231const tools = body.tools as any[];232expect(tools.find(t => t.name === 'tool_search')).toBeUndefined();233expect(tools.find(t => t.name === 'read_file')).toBeDefined();234});235236it('converts tool_search history even when feature flag is off', () => {237const endpoint = createMockEndpoint('gpt-5.4');238const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService;239configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, false);240241const messages: Raw.ChatMessage[] = [242{ role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] },243{244role: Raw.ChatRole.Assistant,245content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Let me search for tools.' }],246toolCalls: [{ id: 'call_ts1', type: 'function', function: { name: 'tool_search', arguments: '{"query":"file tools"}' } }],247},248{249role: Raw.ChatRole.Tool,250toolCallId: 'call_ts1',251content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: '["read_file","grep_search"]' }],252},253];254255const options = createMockOptions({ messages });256const body = accessor.get(IInstantiationService).invokeFunction(257createResponsesRequestBody, options, endpoint.model, endpoint258);259260const input = body.input as any[];261// tool_search tool call should be converted to tool_search_call, not function_call262const toolSearchCall = input.find(i => i.type === 'tool_search_call');263expect(toolSearchCall).toBeDefined();264expect(toolSearchCall.execution).toBe('client');265expect(toolSearchCall.call_id).toBe('call_ts1');266267// tool_search result should be converted to tool_search_output, not function_call_output268const toolSearchOutput = input.find(i => i.type === 'tool_search_output');269expect(toolSearchOutput).toBeDefined();270expect(toolSearchOutput.execution).toBe('client');271expect(toolSearchOutput.call_id).toBe('call_ts1');272273// No tools are currently deferred, so historical tool_search_output should not redeclare them.274const loadedToolNames = (toolSearchOutput.tools as any[]).map((t: any) => t.name);275expect(loadedToolNames).toEqual([]);276277// Should not have any function_call with name tool_search278const badFunctionCall = input.find(i => i.type === 'function_call' && i.name === 'tool_search');279expect(badFunctionCall).toBeUndefined();280});281282it('converts tool_search history when current request has no tools', () => {283const endpoint = createMockEndpoint('gpt-5.4');284const messages: Raw.ChatMessage[] = [285{ role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] },286{287role: Raw.ChatRole.Assistant,288content: [],289toolCalls: [{ id: 'call_ts_no_tools', type: 'function', function: { name: 'tool_search', arguments: '{"query":"file tools"}' } }],290},291{292role: Raw.ChatRole.Tool,293toolCallId: 'call_ts_no_tools',294content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: '["read_file"]' }],295},296];297298const options = createMockOptions({ messages, requestOptions: undefined });299const body = accessor.get(IInstantiationService).invokeFunction(300createResponsesRequestBody, options, endpoint.model, endpoint301);302303const input = body.input as Array<{ type?: string; name?: string; execution?: string; call_id?: string; tools?: unknown[] }>;304expect(input.find(i => i.type === 'tool_search_call')).toMatchObject({305type: 'tool_search_call',306execution: 'client',307call_id: 'call_ts_no_tools',308});309expect(input.find(i => i.type === 'tool_search_output')).toMatchObject({310type: 'tool_search_output',311execution: 'client',312call_id: 'call_ts_no_tools',313tools: [],314});315expect(input.find(i => i.type === 'function_call' && i.name === 'tool_search')).toBeUndefined();316});317318it('excludes non-deferred tools from tool_search_output history', () => {319const messages: Raw.ChatMessage[] = [320{ role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Find file tools' }] },321{322role: Raw.ChatRole.Assistant,323content: [],324toolCalls: [{ id: 'call_ts_file', type: 'function', function: { name: 'tool_search', arguments: '{"query":"file tools"}' } }],325},326{327role: Raw.ChatRole.Tool,328toolCallId: 'call_ts_file',329content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: '["file_search","some_mcp_tool"]' }],330},331{332role: Raw.ChatRole.Assistant,333content: [],334toolCalls: [335{ id: 'call_file', type: 'function', function: { name: 'file_search', arguments: '{"query":"*.ts"}' } },336{ id: 'call_mcp', type: 'function', function: { name: 'some_mcp_tool', arguments: '{"input":"x"}' } },337],338},339];340341const body = createToolSearchScenario(messages);342343const input = body.input as Array<{ type?: string; name?: string; namespace?: string; tools?: Array<{ name: string }> }>;344const toolSearchOutput = input.find(i => i.type === 'tool_search_output');345const fileSearchCall = input.find(i => i.type === 'function_call' && i.name === 'file_search');346const mcpToolCall = input.find(i => i.type === 'function_call' && i.name === 'some_mcp_tool');347348expect({349loadedToolNames: toolSearchOutput?.tools?.map(t => t.name),350fileSearchNamespace: fileSearchCall?.namespace,351mcpToolNamespace: mcpToolCall?.namespace,352}).toEqual({353loadedToolNames: ['some_mcp_tool'],354fileSearchNamespace: undefined,355mcpToolNamespace: 'some_mcp_tool',356});357});358359it('does not load tools from tool_search_output when only non-deferred tools are returned', () => {360const messages: Raw.ChatMessage[] = [361{ role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Find core tools' }] },362{363role: Raw.ChatRole.Assistant,364content: [],365toolCalls: [{ id: 'call_ts_core', type: 'function', function: { name: 'tool_search', arguments: '{"query":"core tools"}' } }],366},367{368role: Raw.ChatRole.Tool,369toolCallId: 'call_ts_core',370content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: '["file_search","read_file"]' }],371},372];373374const body = createToolSearchScenario(messages);375376const input = body.input as Array<{ type?: string; tools?: Array<{ name: string }> }>;377const toolSearchOutput = input.find(i => i.type === 'tool_search_output');378379expect(toolSearchOutput?.tools?.map(t => t.name)).toEqual([]);380});381});382383describe('OpenAIResponsesProcessor tool search events', () => {384function createProcessor() {385const telemetryData = TelemetryData.createAndMarkAsIssued({}, {});386const telemetryService = new SpyingTelemetryService();387const ds = new DisposableStore();388const services = createPlatformServices(ds);389const accessor = services.createTestingAccessor();390return accessor.get(IInstantiationService).createInstance(OpenAIResponsesProcessor, telemetryData, telemetryService, 'req-123', 'gh-req-456', '', undefined);391}392393function collectDeltas(processor: OpenAIResponsesProcessor, events: any[]): IResponseDelta[] {394const deltas: IResponseDelta[] = [];395const finishedCb = async (_text: string, _index: number, delta: IResponseDelta) => {396deltas.push(delta);397return undefined;398};399for (const event of events) {400processor.push({ sequence_number: 0, ...event }, finishedCb);401}402return deltas;403}404405it('handles client tool_search_call as copilotToolCall', () => {406const processor = createProcessor();407const deltas = collectDeltas(processor, [408{409type: 'response.output_item.added',410output_index: 0,411item: {412type: 'tool_search_call' as any,413id: 'ts_002',414execution: 'client',415call_id: 'call_abc',416status: 'in_progress',417arguments: {},418} as any,419},420{421type: 'response.output_item.done',422output_index: 0,423item: {424type: 'tool_search_call' as any,425id: 'ts_002',426execution: 'client',427call_id: 'call_abc',428status: 'completed',429arguments: { query: 'Find shipping tools' },430} as any,431}432]);433434// First delta: beginToolCalls for tool_search435expect(deltas[0].beginToolCalls).toBeDefined();436expect(deltas[0].beginToolCalls![0].name).toBe('tool_search');437expect(deltas[0].beginToolCalls![0].id).toBe('call_abc');438439// Second delta: completed copilotToolCall440expect(deltas[1].copilotToolCalls).toBeDefined();441expect(deltas[1].copilotToolCalls![0]).toMatchObject({442id: 'call_abc',443name: 'tool_search',444arguments: '{"query":"Find shipping tools"}',445});446});447});448449450