Path: blob/main/extensions/copilot/src/extension/prompts/node/panel/test/toolCalling.spec.ts
13406 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 { describe, expect, test } from 'vitest';6import type * as vscode from 'vscode';7import { IChatHookService, type IPreToolUseHookResult } from '../../../../../platform/chat/common/chatHookService';8import { ConfigKey, IConfigurationService } from '../../../../../platform/configuration/common/configurationService';9import { IEndpointProvider } from '../../../../../platform/endpoint/common/endpointProvider';10import { DeferredPromise } from '../../../../../util/vs/base/common/async';11import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';12import { Event } from '../../../../../util/vs/base/common/event';13import { constObservable } from '../../../../../util/vs/base/common/observable';14import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';15import { LanguageModelDataPart, LanguageModelTextPart, LanguageModelToolResult } from '../../../../../vscodeTypes';16import { ChatVariablesCollection } from '../../../../prompt/common/chatVariablesCollection';17import type { Conversation } from '../../../../prompt/common/conversation';18import type { IBuildPromptContext, IToolCallRound } from '../../../../prompt/common/intents';19import { createExtensionUnitTestingServices } from '../../../../test/node/services';20import { ToolName } from '../../../../tools/common/toolNames';21import { IToolsService, type IToolValidationResult } from '../../../../tools/common/toolsService';22import { renderPromptElement } from '../../base/promptRenderer';23import { ChatToolCalls } from '../toolCalling';2425class CapturingChatHookService implements IChatHookService {26declare readonly _serviceBrand: undefined;2728public lastPreToolUseCall: {29readonly toolName: string;30readonly toolInput: unknown;31readonly toolCallId: string;32readonly hooks: vscode.ChatRequestHooks | undefined;33readonly sessionId: string | undefined;34readonly token: vscode.CancellationToken | undefined;35} | undefined;3637public postToolUseCalled = false;3839constructor(40private readonly hookResult: IPreToolUseHookResult | undefined,41) { }4243logConfiguredHooks(): void { }4445async executeHook(): Promise<never[]> {46return [];47}4849async executePreToolUseHook(50toolName: string,51toolInput: unknown,52toolCallId: string,53hooks: vscode.ChatRequestHooks | undefined,54sessionId?: string,55token?: vscode.CancellationToken,56): Promise<IPreToolUseHookResult | undefined> {57this.lastPreToolUseCall = { toolName, toolInput, toolCallId, hooks, sessionId, token };58return this.hookResult;59}6061async executePostToolUseHook(): Promise<undefined> {62this.postToolUseCalled = true;63return undefined;64}65}6667class CapturingToolsService implements IToolsService {68declare readonly _serviceBrand: undefined;6970onWillInvokeTool = Event.None;7172readonly tools: ReadonlyArray<vscode.LanguageModelToolInformation>;73readonly copilotTools = new Map();74readonly modelSpecificTools = constObservable([]);7576public lastInvocation: {77readonly name: string;78readonly options: vscode.LanguageModelToolInvocationOptions<unknown>;79readonly endpointModel: string | undefined;80readonly token: vscode.CancellationToken;81} | undefined;8283public lastToolResult: vscode.LanguageModelToolResult2 | undefined;8485constructor(tool: vscode.LanguageModelToolInformation) {86this.tools = [tool];87}8889getCopilotTool(): undefined {90return undefined;91}9293invokeTool(): Thenable<vscode.LanguageModelToolResult2> {94throw new Error('Not implemented in test');95}9697async invokeToolWithEndpoint(98name: string,99options: vscode.LanguageModelToolInvocationOptions<unknown>,100endpoint: { model: string } | undefined,101token: vscode.CancellationToken,102): Promise<vscode.LanguageModelToolResult2> {103this.lastInvocation = { name, options, endpointModel: endpoint?.model, token };104const result = new LanguageModelToolResult([new LanguageModelTextPart('tool-ok')]);105this.lastToolResult = result;106return result;107}108109getTool(name: string): vscode.LanguageModelToolInformation | undefined {110return this.tools.find(t => t.name === name);111}112113getToolByToolReferenceName(): undefined {114return undefined;115}116117validateToolInput(_name: string, input: string): IToolValidationResult {118return { inputObj: JSON.parse(input) };119}120121validateToolName(): undefined {122return undefined;123}124125getEnabledTools(): vscode.LanguageModelToolInformation[] {126return [];127}128}129130class ParallelAwareToolsService implements IToolsService {131declare readonly _serviceBrand: undefined;132133onWillInvokeTool = Event.None;134135readonly tools: ReadonlyArray<vscode.LanguageModelToolInformation>;136readonly copilotTools = new Map();137readonly modelSpecificTools = constObservable([]);138139public readonly startedCallIds: string[] = [];140private readonly pendingCalls = new Map<string, DeferredPromise<vscode.LanguageModelToolResult2>>();141private readonly startedWaiters: Array<{ expectedCount: number; deferred: DeferredPromise<void> }> = [];142143constructor(tool: vscode.LanguageModelToolInformation) {144this.tools = [tool];145}146147getCopilotTool(): undefined {148return undefined;149}150151invokeTool(): Thenable<vscode.LanguageModelToolResult2> {152throw new Error('Not implemented in test');153}154155invokeToolWithEndpoint(156_name: string,157options: vscode.LanguageModelToolInvocationOptions<unknown>,158_endpoint: { model: string } | undefined,159_token: vscode.CancellationToken,160): Promise<vscode.LanguageModelToolResult2> {161const callId = options.chatStreamToolCallId ?? `missing-${this.startedCallIds.length}`;162this.startedCallIds.push(callId);163this.resolveStartedWaiters();164const deferred = new DeferredPromise<vscode.LanguageModelToolResult2>();165this.pendingCalls.set(callId, deferred);166return deferred.p;167}168169waitForStartedCalls(expectedCount: number): Promise<void> {170if (this.startedCallIds.length >= expectedCount) {171return Promise.resolve();172}173174const deferred = new DeferredPromise<void>();175this.startedWaiters.push({ expectedCount, deferred });176return deferred.p;177}178179private resolveStartedWaiters(): void {180for (let index = this.startedWaiters.length - 1; index >= 0; index--) {181const waiter = this.startedWaiters[index];182if (this.startedCallIds.length >= waiter.expectedCount) {183void waiter.deferred.complete();184this.startedWaiters.splice(index, 1);185}186}187}188189resolveCall(callId: string, value = 'tool-ok'): void {190const pending = this.pendingCalls.get(callId);191if (!pending) {192throw new Error(`Missing pending call: ${callId}`);193}194195void pending.complete(new LanguageModelToolResult([new LanguageModelTextPart(value)]));196this.pendingCalls.delete(callId);197}198199getTool(name: string): vscode.LanguageModelToolInformation | undefined {200return this.tools.find(t => t.name === name);201}202203getToolByToolReferenceName(): undefined {204return undefined;205}206207validateToolInput(_name: string, input: string): IToolValidationResult {208return { inputObj: JSON.parse(input) };209}210211validateToolName(): undefined {212return undefined;213}214215getEnabledTools(): vscode.LanguageModelToolInformation[] {216return [];217}218}219220describe('ChatToolCalls (toolCalling.tsx)', () => {221test('starts multiple sub-agent tool calls in parallel', async () => {222const toolName = ToolName.CoreRunSubagent;223const firstCallId = 'subagent-call-1';224const secondCallId = 'subagent-call-2';225226const toolInfo: vscode.LanguageModelToolInformation = {227name: toolName,228description: 'sub-agent tool',229source: undefined,230inputSchema: undefined,231tags: [],232};233234const testingServiceCollection = createExtensionUnitTestingServices();235const toolsService = new ParallelAwareToolsService(toolInfo);236testingServiceCollection.define(IToolsService, toolsService);237238const accessor = testingServiceCollection.createTestingAccessor();239const instantiationService = accessor.get(IInstantiationService);240const endpointProvider = accessor.get(IEndpointProvider);241const endpoint = await endpointProvider.getChatEndpoint('copilot-base');242243const round: IToolCallRound = {244id: 'round-1',245response: 'calling sub-agents',246toolInputRetry: 0,247toolCalls: [248{ name: toolName, arguments: JSON.stringify({ query: 'one' }), id: firstCallId },249{ name: toolName, arguments: JSON.stringify({ query: 'two' }), id: secondCallId },250],251};252253const promptContext: IBuildPromptContext = {254query: 'test',255history: [],256chatVariables: new ChatVariablesCollection(),257conversation: { sessionId: 'session-123' } as unknown as Conversation,258request: {} as vscode.ChatRequest,259tools: {260toolReferences: [],261toolInvocationToken: {} as vscode.ChatParticipantToolToken,262availableTools: [toolInfo],263},264};265266const renderPromise = renderPromptElement(instantiationService, endpoint, ChatToolCalls, {267promptContext,268toolCallRounds: [round],269toolCallResults: undefined,270});271272await toolsService.waitForStartedCalls(2);273274expect(toolsService.startedCallIds).toEqual([firstCallId, secondCallId]);275276toolsService.resolveCall(firstCallId);277toolsService.resolveCall(secondCallId);278279await renderPromise;280});281282test('calls preToolUse hook with validated input and respects hook output', async () => {283const toolName = 'myTool';284const toolArgs = JSON.stringify({ x: 1 });285const toolCallId = 'call-1';286const hookContext = 'extra policy context';287288const updatedInput = { x: 2, safe: true };289const hooks: vscode.ChatRequestHooks = { PreToolUse: [] };290291const hookResult: IPreToolUseHookResult = {292permissionDecision: 'ask',293permissionDecisionReason: 'Needs confirmation',294updatedInput,295additionalContext: [hookContext],296};297298const toolInfo: vscode.LanguageModelToolInformation = {299name: toolName,300description: 'test tool',301source: undefined,302inputSchema: undefined,303tags: [],304};305306const testingServiceCollection = createExtensionUnitTestingServices();307const toolsService = new CapturingToolsService(toolInfo);308const hookService = new CapturingChatHookService(hookResult);309testingServiceCollection.define(IToolsService, toolsService);310testingServiceCollection.define(IChatHookService, hookService);311312const accessor = testingServiceCollection.createTestingAccessor();313const instantiationService = accessor.get(IInstantiationService);314const endpointProvider = accessor.get(IEndpointProvider);315const endpoint = await endpointProvider.getChatEndpoint('copilot-base');316317const round: IToolCallRound = {318id: 'round-1',319response: 'calling tool',320toolInputRetry: 0,321toolCalls: [{ name: toolName, arguments: toolArgs, id: toolCallId }],322};323324const conversation = { sessionId: 'session-123' } as unknown as Conversation;325const promptContext: IBuildPromptContext = {326query: 'test',327history: [],328chatVariables: new ChatVariablesCollection(),329conversation,330request: { hooks } as unknown as vscode.ChatRequest,331tools: {332toolReferences: [],333toolInvocationToken: {} as vscode.ChatParticipantToolToken,334availableTools: [toolInfo],335},336};337338await renderPromptElement(instantiationService, endpoint, ChatToolCalls, {339promptContext,340toolCallRounds: [round],341toolCallResults: undefined,342});343344// Hook called with validated (original) input345expect(hookService.lastPreToolUseCall).toEqual({346toolName,347toolInput: { x: 1 },348toolCallId,349hooks,350sessionId: 'session-123',351token: CancellationToken.None,352});353354// Tool invoked with updatedInput from hook355expect(toolsService.lastInvocation?.name).toBe(toolName);356expect(toolsService.lastInvocation?.options.input).toEqual(updatedInput);357expect(toolsService.lastInvocation?.options.preToolUseResult).toEqual({358permissionDecision: 'ask',359permissionDecisionReason: 'Needs confirmation',360updatedInput,361});362363// Hook additionalContext is appended to the tool result content364const contentText = (toolsService.lastToolResult?.content ?? [])365.filter((p): p is LanguageModelTextPart => p instanceof LanguageModelTextPart)366.map(p => p.value)367.join('\n');368expect(contentText).toContain('<PreToolUse-context>');369expect(contentText).toContain(hookContext);370});371372test('skips postToolUse hook when preToolUse denies the tool but still appends preToolUse context', async () => {373const toolName = 'blockedTool';374const toolArgs = JSON.stringify({ cmd: 'dangerous' });375const toolCallId = 'call-denied';376const denyContext = 'This tool was blocked by policy';377378const hookResult: IPreToolUseHookResult = {379permissionDecision: 'deny',380permissionDecisionReason: 'Blocked by security policy',381additionalContext: [denyContext],382};383384const toolInfo: vscode.LanguageModelToolInformation = {385name: toolName,386description: 'blocked tool',387source: undefined,388inputSchema: undefined,389tags: [],390};391392const testingServiceCollection = createExtensionUnitTestingServices();393const toolsService = new CapturingToolsService(toolInfo);394const hookService = new CapturingChatHookService(hookResult);395testingServiceCollection.define(IToolsService, toolsService);396testingServiceCollection.define(IChatHookService, hookService);397398const accessor = testingServiceCollection.createTestingAccessor();399const instantiationService = accessor.get(IInstantiationService);400const endpointProvider = accessor.get(IEndpointProvider);401const endpoint = await endpointProvider.getChatEndpoint('copilot-base');402403const round: IToolCallRound = {404id: 'round-1',405response: 'calling tool',406toolInputRetry: 0,407toolCalls: [{ name: toolName, arguments: toolArgs, id: toolCallId }],408};409410const hooks: vscode.ChatRequestHooks = { PreToolUse: [] };411const promptContext: IBuildPromptContext = {412query: 'test',413history: [],414chatVariables: new ChatVariablesCollection(),415conversation: { sessionId: 'session-deny' } as unknown as Conversation,416request: { hooks } as unknown as vscode.ChatRequest,417tools: {418toolReferences: [],419toolInvocationToken: {} as vscode.ChatParticipantToolToken,420availableTools: [toolInfo],421},422};423424await renderPromptElement(instantiationService, endpoint, ChatToolCalls, {425promptContext,426toolCallRounds: [round],427toolCallResults: undefined,428});429430// PreToolUse hook was called431expect(hookService.lastPreToolUseCall).toBeDefined();432433// PostToolUse hook should NOT have been called since PreToolUse denied the tool434expect(hookService.postToolUseCalled).toBe(false);435436// The tool is still invoked with the deny preToolUseResult passed through437expect(toolsService.lastInvocation).toBeDefined();438expect(toolsService.lastInvocation?.name).toBe('blockedTool');439expect(toolsService.lastInvocation?.options.preToolUseResult).toEqual({440permissionDecision: 'deny',441permissionDecisionReason: 'Blocked by security policy',442updatedInput: undefined,443});444// PreToolUse context should still be appended to the tool result445const contentText = (toolsService.lastToolResult?.content ?? [])446.filter((p): p is LanguageModelTextPart => p instanceof LanguageModelTextPart)447.map(p => p.value)448.join('\n');449expect(contentText).toContain('<PreToolUse-context>');450expect(contentText).toContain(denyContext);451expect(contentText).not.toContain('<PostToolUse-context>');452});453454test('replaces images with placeholders for historical turns', async () => {455const toolName = 'viewImage';456const toolCallId = 'call-img-1';457458const toolInfo: vscode.LanguageModelToolInformation = {459name: toolName,460description: 'view image tool',461source: undefined,462inputSchema: undefined,463tags: [],464};465466const testingServiceCollection = createExtensionUnitTestingServices();467const toolsService = new CapturingToolsService(toolInfo);468testingServiceCollection.define(IToolsService, toolsService);469470const accessor = testingServiceCollection.createTestingAccessor();471const instantiationService = accessor.get(IInstantiationService);472const endpointProvider = accessor.get(IEndpointProvider);473const endpoint = await endpointProvider.getChatEndpoint('copilot-base');474475const imageData = new Uint8Array(1024);476const toolCallResults: Record<string, vscode.LanguageModelToolResult> = {477[toolCallId]: new LanguageModelToolResult([478new LanguageModelTextPart('some text result'),479LanguageModelDataPart.image(imageData, 'image/png'),480]),481};482483const round: IToolCallRound = {484id: 'round-1',485response: 'viewing image',486toolInputRetry: 0,487toolCalls: [{ name: toolName, arguments: '{}', id: toolCallId }],488};489490const promptContext: IBuildPromptContext = {491query: 'test',492history: [],493chatVariables: new ChatVariablesCollection(),494conversation: { sessionId: 'session-img' } as unknown as Conversation,495request: {} as vscode.ChatRequest,496tools: {497toolReferences: [],498toolInvocationToken: {} as vscode.ChatParticipantToolToken,499availableTools: [toolInfo],500},501};502503const { messages } = await renderPromptElement(instantiationService, endpoint, ChatToolCalls, {504promptContext,505toolCallRounds: [round],506toolCallResults,507isHistorical: true,508});509510const serialized = JSON.stringify(messages);511expect(serialized).toContain('Image was previously shown to you');512expect(serialized).toContain('some text result');513// Should not contain base64 image data514expect(serialized).not.toContain('image_url');515});516517test('enforces shared image budget across tool results', async () => {518const toolName = 'viewImage';519const firstCallId = 'call-big-1';520const secondCallId = 'call-big-2';521522const toolInfo: vscode.LanguageModelToolInformation = {523name: toolName,524description: 'view image tool',525source: undefined,526inputSchema: undefined,527tags: [],528};529530const testingServiceCollection = createExtensionUnitTestingServices();531const toolsService = new CapturingToolsService(toolInfo);532testingServiceCollection.define(IToolsService, toolsService);533534const accessor = testingServiceCollection.createTestingAccessor();535const instantiationService = accessor.get(IInstantiationService);536const endpointProvider = accessor.get(IEndpointProvider);537const endpoint = await endpointProvider.getChatEndpoint('copilot-base');538539// Disable image uploads so images go through the base64 path where the budget applies540const configService = accessor.get(IConfigurationService);541await configService.setConfig(ConfigKey.EnableChatImageUpload, false);542543// Each image is 3MB — individually exceeds the 2.5MB shared budget (half of 5MB CAPI limit)544const bigImage = new Uint8Array(3 * 1024 * 1024);545const toolCallResults: Record<string, vscode.LanguageModelToolResult> = {546[firstCallId]: new LanguageModelToolResult([547LanguageModelDataPart.image(bigImage, 'image/png'),548]),549[secondCallId]: new LanguageModelToolResult([550LanguageModelDataPart.image(bigImage, 'image/png'),551]),552};553554const round: IToolCallRound = {555id: 'round-1',556response: 'viewing images',557toolInputRetry: 0,558toolCalls: [559{ name: toolName, arguments: '{}', id: firstCallId },560{ name: toolName, arguments: '{}', id: secondCallId },561],562};563564const promptContext: IBuildPromptContext = {565query: 'test',566history: [],567chatVariables: new ChatVariablesCollection(),568conversation: { sessionId: 'session-budget' } as unknown as Conversation,569request: {} as vscode.ChatRequest,570tools: {571toolReferences: [],572toolInvocationToken: {} as vscode.ChatParticipantToolToken,573availableTools: [toolInfo],574},575};576577const { messages } = await renderPromptElement(instantiationService, endpoint, ChatToolCalls, {578promptContext,579toolCallRounds: [round],580toolCallResults,581});582583const serialized = JSON.stringify(messages);584// Both images exceed the 2.5MB shared budget and should be replaced with placeholders585expect(serialized).toContain('context image budget exceeded');586expect(serialized).not.toContain('image_url');587});588});589590591