Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliPromptResolver.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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';6import { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService';7import { IIgnoreService, NullIgnoreService } from '../../../../../platform/ignore/common/ignoreService';8import { ILogService } from '../../../../../platform/log/common/logService';9import { NullWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';10import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';11import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';12import { URI } from '../../../../../util/vs/base/common/uri';13import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';14import { IVSCodeExtensionContext } from '../../../../../platform/extContext/common/extensionContext';15import { MockExtensionContext } from '../../../../../platform/test/node/extensionContext';16import { createExtensionUnitTestingServices } from '../../../../test/node/services';17import { TestChatRequest } from '../../../../test/node/testHelpers';18import { IWorkspaceInfo } from '../../../common/workspaceInfo';19import { ICopilotCLIImageSupport } from '../copilotCLIImageSupport';20import { CopilotCLIPromptResolver } from '../copilotcliPromptResolver';21import { MockSkillLocations, NullICopilotCLIImageSupport } from './testHelpers';2223// Mock generateUserPrompt to avoid TSX rendering complexity in unit tests24vi.mock('../../../../prompts/node/agent/copilotCLIPrompt', () => ({25generateUserPrompt: vi.fn(async (_request: unknown, prompt: string | undefined, _variables: unknown, _instantiationService: unknown) => prompt ?? ''),26}));2728const noopWorkspaceInfo: IWorkspaceInfo = {29folder: undefined,30repository: undefined,31worktree: undefined,32worktreeProperties: undefined,33};3435function makePromptFileReference(uri: URI) {36return {37id: 'vscode.prompt.file',38value: uri,39name: uri.fsPath,40};41}4243describe('CopilotCLIPromptResolver', () => {44let disposables: DisposableStore;45let resolver: CopilotCLIPromptResolver;46let skillLocations: MockSkillLocations;47let logService: ILogService;48let instantiationService: IInstantiationService;4950beforeEach(() => {51disposables = new DisposableStore();52const services = disposables.add(createExtensionUnitTestingServices());53const accessor = disposables.add(services.createTestingAccessor());54logService = accessor.get(ILogService);55instantiationService = accessor.get(IInstantiationService);56skillLocations = new MockSkillLocations();57});5859afterEach(() => {60disposables.dispose();61});6263function createResolver(overrideSkillLocations?: MockSkillLocations, overrideExtensionContext?: IVSCodeExtensionContext) {64const imageSupport = new NullICopilotCLIImageSupport();65const workspaceService = new NullWorkspaceService();66const ignoreService = new NullIgnoreService();67const fileSystemService = new MockFileSystemService();68const extensionContext = overrideExtensionContext ?? new MockExtensionContext() as unknown as IVSCodeExtensionContext;69return new CopilotCLIPromptResolver(70imageSupport as unknown as ICopilotCLIImageSupport,71logService,72fileSystemService,73workspaceService,74instantiationService,75ignoreService as unknown as IIgnoreService,76overrideSkillLocations ?? skillLocations,77extensionContext,78);79}8081describe('resolvePrompt', () => {82it('returns the prompt and empty attachments for a basic request with no references', async () => {83resolver = createResolver();84const request = new TestChatRequest('hello world');85const result = await resolver.resolvePrompt(request, undefined, [], noopWorkspaceInfo, [], CancellationToken.None);86expect(result.prompt).toBe('hello world');87expect(result.attachments).toHaveLength(0);88expect(result.references).toHaveLength(0);89});9091it('uses the provided prompt override instead of request.prompt', async () => {92resolver = createResolver();93const request = new TestChatRequest('original prompt');94const result = await resolver.resolvePrompt(request, 'override prompt', [], noopWorkspaceInfo, [], CancellationToken.None);95expect(result.prompt).toBe('override prompt');96});9798it('cancellation token: returns empty result when cancelled', async () => {99resolver = createResolver();100const request = new TestChatRequest('hello');101const cancelledToken = CancellationToken.Cancelled;102const result = await resolver.resolvePrompt(request, undefined, [], noopWorkspaceInfo, [], cancelledToken);103expect(result.attachments).toHaveLength(0);104expect(result.references).toHaveLength(0);105});106});107108describe('skill prompt file filtering', () => {109it('excludes prompt file references that are within known skill locations', async () => {110const skillsDir = URI.file('/home/user/.skills');111const skillFile = URI.joinPath(skillsDir, 'my-skill.prompt.md');112skillLocations = new MockSkillLocations([skillsDir]);113resolver = createResolver(skillLocations);114115const request = new TestChatRequest('use the skill', [makePromptFileReference(skillFile)]);116const result = await resolver.resolvePrompt(request, undefined, [], noopWorkspaceInfo, [], CancellationToken.None);117118// The prompt file is within the known skill location, so it should not appear in references119expect(result.references).toHaveLength(0);120expect(result.attachments).toHaveLength(0);121});122123it('includes prompt file references that are NOT within known skill locations', async () => {124const skillsDir = URI.file('/home/user/.skills');125const nonSkillPromptFile = URI.file('/workspace/some-other.prompt.md');126skillLocations = new MockSkillLocations([skillsDir]);127resolver = createResolver(skillLocations);128129const request = new TestChatRequest('use a prompt file', [makePromptFileReference(nonSkillPromptFile)]);130const result = await resolver.resolvePrompt(request, undefined, [], noopWorkspaceInfo, [], CancellationToken.None);131132// The prompt file is NOT in a skill location, so it should appear in references133expect(result.references).toHaveLength(1);134expect((result.references[0].value as URI).fsPath).toBe(nonSkillPromptFile.fsPath);135});136137it('excludes prompt file when it is in a subdirectory of a known skill location', async () => {138const skillsDir = URI.file('/home/user/.skills');139const nestedSkillFile = URI.joinPath(skillsDir, 'subdir', 'nested.prompt.md');140skillLocations = new MockSkillLocations([skillsDir]);141resolver = createResolver(skillLocations);142143const request = new TestChatRequest('use nested skill', [makePromptFileReference(nestedSkillFile)]);144const result = await resolver.resolvePrompt(request, undefined, [], noopWorkspaceInfo, [], CancellationToken.None);145146expect(result.references).toHaveLength(0);147});148149it('includes prompt file when no skill locations are configured', async () => {150skillLocations = new MockSkillLocations([]);151resolver = createResolver(skillLocations);152153const promptFile = URI.file('/workspace/my.prompt.md');154const request = new TestChatRequest('use prompt', [makePromptFileReference(promptFile)]);155const result = await resolver.resolvePrompt(request, undefined, [], noopWorkspaceInfo, [], CancellationToken.None);156157// No skill locations match, so prompt file goes through the full pipeline158expect(result.references).toHaveLength(1);159});160161it('excludes plan.prompt.md when it is in the prompts directory that is a parent of the extension', async () => {162skillLocations = new MockSkillLocations([]);163const extensionContext = new MockExtensionContext() as unknown as IVSCodeExtensionContext;164resolver = createResolver(skillLocations, extensionContext);165166// The condition checks isEqualOrParent(extensionUri, directory), meaning167// directory must be a parent of or equal to extensionUri.168// extensionUri = /mock-extension, so place plan.prompt.md at /prompts/plan.prompt.md169// where directory = / which IS a parent of /mock-extension.170// But path.basename must be 'prompts', so we need /mock-extension/prompts as parent,171// which means extensionUri must be under that. Construct extensionUri accordingly.172const prompts = URI.file('/test-ext/prompts');173// Override extensionUri to be a child of /test-ext/prompts174(extensionContext as any).extensionUri = URI.joinPath(prompts, 'inner-ext');175resolver = createResolver(skillLocations, extensionContext);176177const planPromptFile = URI.joinPath(prompts, 'plan.prompt.md');178const request = new TestChatRequest('implement this', [makePromptFileReference(planPromptFile)]);179const result = await resolver.resolvePrompt(request, undefined, [], noopWorkspaceInfo, [], CancellationToken.None);180181// plan.prompt.md from a prompts directory that is a parent of extensionUri should be excluded182expect(result.references).toHaveLength(0);183expect(result.attachments).toHaveLength(0);184});185186it('includes plan.prompt.md when it is NOT in the extension prompts directory', async () => {187skillLocations = new MockSkillLocations([]);188const extensionContext = new MockExtensionContext() as unknown as IVSCodeExtensionContext;189resolver = createResolver(skillLocations, extensionContext);190191const planPromptFile = URI.file('/workspace/plan.prompt.md');192const request = new TestChatRequest('implement this', [makePromptFileReference(planPromptFile)]);193const result = await resolver.resolvePrompt(request, undefined, [], noopWorkspaceInfo, [], CancellationToken.None);194195// plan.prompt.md from a workspace directory (not extension prompts dir) should be included196expect(result.references).toHaveLength(1);197});198});199});200201202