Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliPromptResolver.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 type { Attachment } from '@github/copilot/sdk';6import type * as vscode from 'vscode';7import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';8import { IIgnoreService } from '../../../../platform/ignore/common/ignoreService';9import { ILogService } from '../../../../platform/log/common/logService';10import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';11import { isLocation, toLocation } from '../../../../util/common/types';12import { raceCancellation } from '../../../../util/vs/base/common/async';13import { ResourceMap } from '../../../../util/vs/base/common/map';14import { Schemas } from '../../../../util/vs/base/common/network';15import * as path from '../../../../util/vs/base/common/path';16import { extUriBiasedIgnorePathCase, relativePath } from '../../../../util/vs/base/common/resources';17import { URI } from '../../../../util/vs/base/common/uri';18import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';19import { ChatReferenceBinaryData, ChatReferenceDiagnostic, FileType, Location } from '../../../../vscodeTypes';20import { ChatVariablesCollection, isCustomizationsIndex, isInstructionFile, isPromptFile, PromptVariable } from '../../../prompt/common/chatVariablesCollection';21import { generateUserPrompt } from '../../../prompts/node/agent/copilotCLIPrompt';22import { getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../../common/workspaceInfo';23import { ICopilotCLIImageSupport, isImageMimeType } from './copilotCLIImageSupport';24import { ICopilotCLISkills } from './copilotCLISkills';25import { CancellationToken } from '../../../../util/vs/base/common/cancellation';26import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';2728export class CopilotCLIPromptResolver {29constructor(30@ICopilotCLIImageSupport private readonly imageSupport: ICopilotCLIImageSupport,31@ILogService private readonly logService: ILogService,32@IFileSystemService private readonly fileSystemService: IFileSystemService,33@IWorkspaceService private readonly workspaceService: IWorkspaceService,34@IInstantiationService private readonly instantiationService: IInstantiationService,35@IIgnoreService private readonly ignoreService: IIgnoreService,36@ICopilotCLISkills private readonly skillsService: ICopilotCLISkills,37@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,38) { }3940/**41* Generates the final prompt for the Copilot CLI agent, resolving variables and preparing attachments.42* @param prompt Provide a prompt to override the request prompt43*/44public async resolvePrompt(request: vscode.ChatRequest, prompt: string | undefined, additionalReferences: vscode.ChatPromptReference[], workspaceInfo: IWorkspaceInfo, additionalWorkspaces: IWorkspaceInfo[], token: vscode.CancellationToken): Promise<{ prompt: string; attachments: Attachment[]; references: vscode.ChatPromptReference[] }> {45const allReferences = new ChatVariablesCollection(request.references.concat(additionalReferences.filter(ref => !request.references.includes(ref))));46prompt = prompt ?? request.prompt;47const [variables, attachments] = await this.constructChatVariablesAndAttachments(allReferences, workspaceInfo, additionalWorkspaces, token);48if (token.isCancellationRequested) {49return { prompt, attachments: [], references: [] };50}51prompt = await raceCancellation(generateUserPrompt(request, prompt, variables, this.instantiationService), token);52const references = Array.from(variables).map(v => v.reference);53return { prompt: prompt ?? '', attachments, references };54}5556/**57* Builds a map from workspace folder URIs to their corresponding worktree URIs.58* Used for multi-folder path translation when isolation is enabled.59*/60private buildFolderToWorktreeMap(primaryWorkspaceInfo: IWorkspaceInfo, additionalWorkspaces: IWorkspaceInfo[]): ResourceMap<vscode.Uri> {61const map = new ResourceMap<vscode.Uri>();62if (primaryWorkspaceInfo.worktree && primaryWorkspaceInfo.repository) {63map.set(primaryWorkspaceInfo.repository, primaryWorkspaceInfo.worktree);64}65for (const ws of additionalWorkspaces) {66if (ws.worktree && ws.repository) {67map.set(ws.repository, ws.worktree);68}69}70return map;71}7273private async constructChatVariablesAndAttachments(variables: ChatVariablesCollection, workspaceInfo: IWorkspaceInfo, additionalWorkspaces: IWorkspaceInfo[], token: vscode.CancellationToken): Promise<[variables: ChatVariablesCollection, Attachment[]]> {74const validReferences: vscode.ChatPromptReference[] = [];75const fileFolderReferences: vscode.ChatPromptReference[] = [];76const builtinSlashCommandReferences: vscode.ChatPromptReference[] = [];77const isolationEnabled = isIsolationEnabled(workspaceInfo) || additionalWorkspaces.some(ws => isIsolationEnabled(ws));78const folderToWorktreeMap = this.buildFolderToWorktreeMap(workspaceInfo, additionalWorkspaces);79const hasAnyWorkingDirectory = getWorkingDirectory(workspaceInfo) || additionalWorkspaces.some(ws => getWorkingDirectory(ws));80const knownSkillLocations = await this.skillsService.getSkillsLocations(CancellationToken.None);81await Promise.all(Array.from(variables).map(async variable => {82// Unsupported references: prompt instructions, instruction files, and the customizations index.83if (isInstructionFile(variable) || isCustomizationsIndex(variable)) {84return;85}86// No need to include skill prompt files as an attachment if CLI already knows about them.87const promptFileUri = isPromptFile(variable) ? variable.value : undefined;88if (promptFileUri) {89if (knownSkillLocations.some(loc => extUriBiasedIgnorePathCase.isEqualOrParent(promptFileUri, loc))) {90return;91}92// Exclude plan prompt file from Core.93const directory = URI.file(path.dirname(promptFileUri.fsPath));94if (promptFileUri.fsPath.endsWith('plan.prompt.md') && path.basename(directory.fsPath) === 'prompts' && extUriBiasedIgnorePathCase.isEqualOrParent(this.extensionContext.extensionUri, directory)) {95return;96}97}98// GitHub pull request references99if (isGitHubPullRequestReference(variable.reference)) {100builtinSlashCommandReferences.push(variable.reference);101return;102}103// Git merge changes references104if (isGitMergeChangesReference(variable.reference)) {105builtinSlashCommandReferences.push(variable.reference);106return;107}108// If isolation is enabled, and we have workspace repo information, skip it.109if (isolationEnabled && isWorkspaceRepoInformationItem(variable)) {110return;111}112const variableRef = (!isolationEnabled || !hasAnyWorkingDirectory) ? variable.reference : await this.translateWorkspaceRefToWorkingDirectoryRef(variable.reference, workspaceInfo, additionalWorkspaces, folderToWorktreeMap, token);113// Images will be attached using regular attachments via Copilot CLI SDK.114if (variableRef.value instanceof ChatReferenceBinaryData) {115if (!isImageMimeType(variableRef.value.mimeType)) {116validReferences.push(variableRef);117}118fileFolderReferences.push(variableRef);119return;120}121if (isLocation(variableRef.value)) {122if (await this.ignoreService.isCopilotIgnored(variableRef.value.uri)) {123return;124}125fileFolderReferences.push(variableRef);126validReferences.push(variableRef);127return;128}129// Notebooks are not supported yet.130if (URI.isUri(variableRef.value)) {131if (await this.ignoreService.isCopilotIgnored(variableRef.value)) {132return;133}134if (variableRef.value.scheme === Schemas.vscodeNotebookCellOutput || variableRef.value.scheme === Schemas.vscodeNotebookCellOutput) {135return;136}137138// Files and directories will be attached using regular attachments via Copilot CLI SDK.139validReferences.push(variableRef);140fileFolderReferences.push(variableRef);141return;142}143144validReferences.push(variableRef);145}));146147const [attachments, imageAttachments] = await this.constructFileOrFolderAttachments(fileFolderReferences, token);148// Re-add the images after we've copied them to the image store.149imageAttachments.forEach(img => {150if (img.type === 'file') {151validReferences.push({152name: img.displayName,153value: URI.file(img.path),154id: img.path,155});156}157});158159// Add attachments for built-in slash command references160for (const reference of builtinSlashCommandReferences) {161// GitHub pull request reference162if (isGitHubPullRequestReference(reference) && URI.isUri(reference.value)) {163attachments.push({164type: 'blob',165mimeType: 'text/plain',166data: reference.value.toString(),167});168}169170// Git merge changes reference171if (isGitMergeChangesReference(reference) && typeof reference.value === 'string') {172attachments.push({173type: 'blob',174mimeType: 'text/plain',175data: reference.value,176});177}178}179180variables = new ChatVariablesCollection(validReferences);181return [variables, attachments];182}183184185private async constructFileOrFolderAttachments(fileOrFolderReferences: vscode.ChatPromptReference[], token: vscode.CancellationToken): Promise<[Attachment[], image: Attachment[]]> {186const attachments: Attachment[] = [];187const images: Attachment[] = [];188await Promise.all(fileOrFolderReferences.map(async ref => {189if (ref.value instanceof ChatReferenceBinaryData) {190if (!isImageMimeType(ref.value.mimeType)) {191return;192}193// Handle image attachments194try {195const buffer = await ref.value.data();196const uri = await this.imageSupport.storeImage(buffer, ref.value.mimeType);197attachments.push({198type: 'file',199displayName: ref.name,200path: uri.fsPath201});202images.push({203type: 'file',204displayName: ref.name,205path: uri.fsPath206});207} catch (error) {208this.logService.error(`[CopilotCLISession] Failed to store image: ${error}`);209}210return;211}212213if (isLocation(ref.value)) {214try {215// Open the document and get the text for the range.216const document = await raceCancellation(this.workspaceService.openTextDocument(ref.value.uri), token);217if (!document) {218return;219}220attachments.push({221type: 'selection',222displayName: ref.name,223filePath: ref.value.uri.fsPath,224selection: {225start: {226line: ref.value.range.start.line + 1,227character: ref.value.range.start.character + 1228},229end: {230line: ref.value.range.end.line + 1,231character: ref.value.range.end.character + 1232}233},234text: document.getText(ref.value.range)235});236}237catch (ex) {238this.logService.error(`[CopilotCLISession] Failed to attach location ${ref.value.uri.fsPath}: ${ex}`);239}240return;241}242243const uri = ref.value;244245if (!URI.isUri(uri)) {246return;247}248249// Attachment of Source control items.250if (uri.scheme === 'scm-history-item') {251return;252}253254try {255const stat = await raceCancellation(this.fileSystemService.stat(uri), token);256if (!stat) {257return;258}259const type = stat.type === FileType.Directory ? 'directory' : stat.type === FileType.File ? 'file' : undefined;260if (!type) {261this.logService.error(`[CopilotCLISession] Ignoring attachment as it's not a file/directory (${uri.fsPath})`);262return;263}264attachments.push({265type,266displayName: ref.name || path.basename(uri.fsPath),267path: uri.fsPath268});269} catch (error) {270this.logService.error(`[CopilotCLISession] Failed to attach ${uri.fsPath}: ${error}`);271}272}));273274return [attachments, images];275}276277private async translateWorkspaceRefToWorkingDirectoryRef(ref: vscode.ChatPromptReference, workspaceInfo: IWorkspaceInfo, additionalWorkspaces: IWorkspaceInfo[], folderToWorktreeMap: ResourceMap<vscode.Uri>, token: vscode.CancellationToken): Promise<vscode.ChatPromptReference> {278try {279if (ref.value instanceof ChatReferenceBinaryData) {280return ref;281}282283if (isLocation(ref.value)) {284const uri = await this.translateWorkspaceUriToWorkingDirectoryUri(ref.value.uri, workspaceInfo, additionalWorkspaces, folderToWorktreeMap, token);285const loc = new Location(uri, toLocation(ref.value)!.range);286return {287...ref,288value: loc289};290} else if (URI.isUri(ref.value)) {291const uri = await this.translateWorkspaceUriToWorkingDirectoryUri(ref.value, workspaceInfo, additionalWorkspaces, folderToWorktreeMap, token);292return {293...ref,294value: uri295};296} else if (ref.value instanceof ChatReferenceDiagnostic) {297const diagnostics = await Promise.all(ref.value.diagnostics.map(async ([uri, diags]) => {298const translatedUri = await this.translateWorkspaceUriToWorkingDirectoryUri(uri, workspaceInfo, additionalWorkspaces, folderToWorktreeMap, token);299return [translatedUri, diags] as [vscode.Uri, vscode.Diagnostic[]];300}));301return {302...ref,303value: new ChatReferenceDiagnostic(diagnostics)304};305}306return ref;307} catch (error) {308this.logService.error(error, `[CopilotCLISession] Failed to translate workspace reference`);309return ref;310}311}312313private async translateWorkspaceUriToWorkingDirectoryUri(uri: vscode.Uri, workspaceInfo: IWorkspaceInfo, additionalWorkspaces: IWorkspaceInfo[], folderToWorktreeMap: ResourceMap<vscode.Uri>, token: vscode.CancellationToken): Promise<vscode.Uri> {314const workspaceFolder = this.workspaceService.getWorkspaceFolder(uri);315const matchingWorktree = workspaceFolder ? folderToWorktreeMap.get(workspaceFolder) : undefined;316if (!workspaceFolder || !matchingWorktree) {317return (await this.findMatchingWorktree(uri, workspaceInfo, additionalWorkspaces, token)) ?? uri;318}319// Use the folder-specific worktree from the map when available; otherwise, fall back to a best-effort worktree match (or the original URI)320const targetDir = matchingWorktree;321const rel = relativePath(workspaceFolder, uri);322if (!rel) {323return uri;324}325const segments = rel.split('/');326const candidate = URI.joinPath(targetDir, ...segments);327const candidateStat = await raceCancellation(this.fileSystemService.stat(candidate), token).catch(() => undefined);328return candidateStat ? candidate : uri;329}330331private async findMatchingWorktree(uri: vscode.Uri, workspaceInfo: IWorkspaceInfo, additionalWorkspaces: IWorkspaceInfo[], token: vscode.CancellationToken): Promise<vscode.Uri | undefined> {332// Assume the uri is `/user/abc/projects/project_abc/file.ts` and one of the items in workspaceInfo or additionalWorkspaces has a folder/repositoryUri that is /user/abc/projects/project_abc and that has a worktree at `/user/abc/projects/project_abc-worktree`, we want to translate the file uri to `/user/abc/projects/project_abc-worktree/file.ts`.333for (const ws of [workspaceInfo, ...additionalWorkspaces]) {334if (ws.repository && ws.worktree) {335if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, ws.repository)) {336const rel = relativePath(ws.repository, uri);337if (rel) {338const candidate = URI.joinPath(ws.worktree, rel);339const candidateStat = await raceCancellation(this.fileSystemService.stat(candidate), token).catch(() => undefined);340return candidateStat ? candidate : uri;341}342}343}344}345}346}347348/**349* Never include this variable in Copilot CLI prompts when using git worktrees (isolation).350* This causes issues as the repository information will not match the worktree state.351* https://github.com/microsoft/vscode/issues/279865352*/353function isWorkspaceRepoInformationItem(variable: PromptVariable): boolean {354const ref = variable.reference;355if (typeof ref.value !== 'string') {356return false;357}358if (!ref.modelDescription) {359return false;360}361return (362(ref.modelDescription).startsWith('Information about one of the current repositories') || (ref.modelDescription).startsWith('Information about the current repository'))363&&364ref.value.startsWith('Repository name:');365}366367function isGitHubPullRequestReference(ref: vscode.ChatPromptReference): boolean {368return ref.id === 'github-pull-request';369}370371function isGitMergeChangesReference(ref: vscode.ChatPromptReference): boolean {372return ref.id === 'git-merge-changes';373}374375376