From fe54902f070cb6c0406eaa4338c894c7c7ec6347 Mon Sep 17 00:00:00 2001 From: zamalali <87001156+zamalali@users.noreply.github.com> Date: Tue, 28 Oct 2025 21:44:31 +0100 Subject: [PATCH 1/5] feat(context): add 'Add System Context' command and service Implement github.copilot.chat.addSystemContext and systemContextService. Add related contributions and i18n updates (package.json, package.nls.json). Add helper tools/utilities used by the feature (listDirTool, toolUtils) and wire into prompt/request handler. Note: To surface this choice in the Copilot Chat 'Add Context' QuickPick, a follow-up UI change is required in the chat/workbench UI. --- package.json | 7 + package.nls.json | 1 + .../context/node/systemContextService.ts | 108 ++++++++++++ .../vscode-node/systemContextContribution.ts | 156 ++++++++++++++++++ .../extension/vscode-node/contributions.ts | 2 + .../extension/vscode-node/services.ts | 2 + .../node/chatParticipantRequestHandler.ts | 57 ++++++- src/extension/tools/node/listDirTool.tsx | 8 +- src/extension/tools/node/toolUtils.ts | 6 +- 9 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 src/extension/context/node/systemContextService.ts create mode 100644 src/extension/context/vscode-node/systemContextContribution.ts diff --git a/package.json b/package.json index e97017cb00..cefb7f17e0 100644 --- a/package.json +++ b/package.json @@ -1813,6 +1813,12 @@ "icon": "$(terminal)", "category": "Copilot CLI" }, + { + "command": "github.copilot.chat.addSystemContext", + "title": "%github.copilot.command.addSystemContext%", + "category": "Chat", + "icon": "$(folder-library)" + }, { "command": "github.copilot.chat.replay", "title": "Start Chat Replay", @@ -3521,6 +3527,7 @@ "when": "view == workbench.panel.chat.view.copilot", "group": "3_show" }, + { "command": "github.copilot.cloud.sessions.refresh", "when": "view == workbench.view.chat.sessions.copilot-cloud-agent", diff --git a/package.nls.json b/package.nls.json index dc042b9eb6..0630a17cc8 100644 --- a/package.nls.json +++ b/package.nls.json @@ -29,6 +29,7 @@ "github.copilot.command.generateTests": "Generate Tests", "github.copilot.command.openUserPreferences": "Open User Preferences", "github.copilot.command.sendChatFeedback": "Send Chat Feedback", + "github.copilot.command.addSystemContext": "Add System Context", "github.copilot.command.buildLocalWorkspaceIndex": "Build Local Workspace Index", "github.copilot.command.buildRemoteWorkspaceIndex": "Build Remote Workspace Index", "github.copilot.viewsWelcome.signIn": { diff --git a/src/extension/context/node/systemContextService.ts b/src/extension/context/node/systemContextService.ts new file mode 100644 index 0000000000..250c840397 --- /dev/null +++ b/src/extension/context/node/systemContextService.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as npath from 'path'; +import { createServiceIdentifier } from '../../../util/common/services'; +import { isEqual } from '../../../util/vs/base/common/resources'; +import { URI } from '../../../util/vs/base/common/uri'; +import { Emitter, Event } from '../../../util/vs/base/common/event'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { isWindows } from '../../../util/vs/base/common/platform'; + +export const ISystemContextService = createServiceIdentifier('ISystemContextService'); + +export interface ISystemContextService { + readonly _serviceBrand: undefined; + readonly onDidChangeSystemContext: Event; + getSystemPaths(): readonly URI[]; + addSystemPaths(paths: readonly URI[]): void; + replaceSystemPaths(paths: readonly URI[]): void; + removeSystemPath(path: URI): void; + clear(): void; + isSystemPath(uri: URI): boolean; +} + +export class SystemContextService extends Disposable implements ISystemContextService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeSystemContext = this._register(new Emitter()); + readonly onDidChangeSystemContext = this._onDidChangeSystemContext.event; + + private readonly _paths = new Map(); + + getSystemPaths(): readonly URI[] { + return Array.from(this._paths.values()); + } + + addSystemPaths(paths: readonly URI[]): void { + let didChange = false; + for (const path of paths) { + const key = path.toString(); + if (!this._paths.has(key)) { + this._paths.set(key, path); + didChange = true; + } + } + if (didChange) { + this._onDidChangeSystemContext.fire(); + } + } + + replaceSystemPaths(paths: readonly URI[]): void { + this._paths.clear(); + for (const path of paths) { + this._paths.set(path.toString(), path); + } + this._onDidChangeSystemContext.fire(); + } + + removeSystemPath(path: URI): void { + for (const [key, storedPath] of this._paths) { + if (isEqual(storedPath, path)) { + this._paths.delete(key); + this._onDidChangeSystemContext.fire(); + return; + } + } + } + + clear(): void { + if (this._paths.size === 0) { + return; + } + this._paths.clear(); + this._onDidChangeSystemContext.fire(); + } + + isSystemPath(uri: URI): boolean { + const candidateComparable = this.toComparablePath(uri); + for (const stored of this._paths.values()) { + const storedComparable = this.toComparablePath(stored); + + if (candidateComparable === storedComparable) { + return true; + } + + if (this.isSubPath(candidateComparable, storedComparable) || this.isSubPath(storedComparable, candidateComparable)) { + return true; + } + } + return false; + } + + private toComparablePath(uri: URI): string { + const normalized = npath.normalize(uri.fsPath); + return isWindows ? normalized.toLowerCase() : normalized; + } + + private isSubPath(child: string, potentialParent: string): boolean { + if (potentialParent === child) { + return true; + } + + const parentWithSep = potentialParent.endsWith(npath.sep) ? potentialParent : potentialParent + npath.sep; + return child.startsWith(parentWithSep); + } +} diff --git a/src/extension/context/vscode-node/systemContextContribution.ts b/src/extension/context/vscode-node/systemContextContribution.ts new file mode 100644 index 0000000000..c1bb5100ec --- /dev/null +++ b/src/extension/context/vscode-node/systemContextContribution.ts @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as vscode from 'vscode'; +import { isMacintosh, isWindows } from '../../../util/vs/base/common/platform'; +import { URI } from '../../../util/vs/base/common/uri'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { IExtensionContribution } from '../../common/contributions'; +import { ISystemContextService } from '../node/systemContextService'; + +interface SystemContextQuickPickItem extends vscode.QuickPickItem { + readonly uri?: URI; + readonly isBrowse?: boolean; +} + +const COMMAND_ID = 'github.copilot.chat.addSystemContext'; + +export class SystemContextContribution extends Disposable implements IExtensionContribution { + readonly id = 'systemContext.contribution'; + + constructor( + @ISystemContextService private readonly systemContextService: ISystemContextService, + ) { + super(); + + this._register(vscode.commands.registerCommand(COMMAND_ID, async () => { + const selected = await this.pickSystemPaths(); + if (!selected || selected.length === 0) { + return; + } + + this.systemContextService.addSystemPaths(selected); + + const label = selected.length === 1 ? selected[0].fsPath : vscode.l10n.t('{0} system locations', selected.length.toString()); + const statusMessage = vscode.l10n.t('Added {0} to Copilot context', label); + void vscode.window.setStatusBarMessage(statusMessage, 3000); + })); + } + + private async pickSystemPaths(): Promise { + const items = await this.getQuickPickItems(); + + const picked = await vscode.window.showQuickPick(items, { + placeHolder: vscode.l10n.t('Select system locations to include in Copilot context'), + ignoreFocusOut: true, + canPickMany: true, + }); + + if (!picked) { + return undefined; + } + + const selectedUris: URI[] = []; + for (const item of picked) { + if (item.uri) { + selectedUris.push(item.uri); + } + } + + if (picked.some(item => item.isBrowse)) { + const browseUris = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: true, + openLabel: vscode.l10n.t('Add to Copilot System Context') + }); + + if (browseUris) { + for (const uri of browseUris) { + selectedUris.push(URI.revive(uri)); + } + } + } + + if (selectedUris.length === 0) { + return undefined; + } + + return this.normalizeAndDeduplicate(selectedUris); + } + + private async getQuickPickItems(): Promise { + const items: SystemContextQuickPickItem[] = []; + const defaults = this.getDefaultSystemPaths(); + + for (const uri of defaults) { + items.push({ + label: uri.fsPath, + description: vscode.l10n.t('Suggested system location'), + uri + }); + } + + const envPaths = this.getEnvConfiguredPaths(); + for (const uri of envPaths) { + if (!defaults.some(def => def.toString() === uri.toString())) { + items.push({ + label: uri.fsPath, + description: vscode.l10n.t('From SYSTEM_CONTEXT_PATHS setting'), + uri + }); + } + } + + items.sort((a, b) => a.label.localeCompare(b.label)); + + items.push({ + label: vscode.l10n.t('Browse for folder…'), + description: vscode.l10n.t('Select a custom system folder'), + isBrowse: true + }); + + return items; + } + + private getDefaultSystemPaths(): URI[] { + const defaults: string[] = []; + + if (isWindows) { + defaults.push('C:\\Windows\\System32', 'C:\\ProgramData'); + } else if (isMacintosh) { + defaults.push('/System/Library', '/etc'); + } else { + defaults.push('/etc', '/var/log'); + } + + return defaults + .filter(p => !!p) + .map(p => URI.file(p)); + } + + private getEnvConfiguredPaths(): URI[] { + const envValue = process.env.SYSTEM_CONTEXT_PATHS; + if (!envValue) { + return []; + } + + const delimiter = isWindows ? ';' : ':'; + const segments = envValue.split(delimiter) + .map(segment => segment.trim()) + .filter(segment => segment.length > 0); + + return segments.map(segment => URI.file(path.resolve(segment))); + } + + private normalizeAndDeduplicate(uris: readonly URI[]): URI[] { + const unique = new Map(); + for (const uri of uris) { + unique.set(uri.with({ fragment: '', query: '' }).toString(), uri); + } + return Array.from(unique.values()); + } +} diff --git a/src/extension/extension/vscode-node/contributions.ts b/src/extension/extension/vscode-node/contributions.ts index 5ae36ce3a9..aad0a5643b 100644 --- a/src/extension/extension/vscode-node/contributions.ts +++ b/src/extension/extension/vscode-node/contributions.ts @@ -19,6 +19,7 @@ import { ConversationFeature } from '../../conversation/vscode-node/conversation import { FeedbackCommandContribution } from '../../conversation/vscode-node/feedbackContribution'; import { LanguageModelAccess } from '../../conversation/vscode-node/languageModelAccess'; import { LogWorkspaceStateContribution } from '../../conversation/vscode-node/logWorkspaceState'; +import { SystemContextContribution } from '../../context/vscode-node/systemContextContribution'; import { RemoteAgentContribution } from '../../conversation/vscode-node/remoteAgents'; import { LanguageModelProxyContrib } from '../../externalAgents/vscode-node/lmProxyContrib'; import { WalkthroughCommandContribution } from '../../getting-started/vscode-node/commands'; @@ -70,6 +71,7 @@ export const vscodeNodeContributions: IExtensionContributionFactory[] = [ asContributionFactory(CopilotDebugCommandContribution), asContributionFactory(DebugCommandsContribution), asContributionFactory(LanguageModelAccess), + asContributionFactory(SystemContextContribution), asContributionFactory(WalkthroughCommandContribution), asContributionFactory(InlineEditProviderFeature), asContributionFactory(SettingsSchemaFeature), diff --git a/src/extension/extension/vscode-node/services.ts b/src/extension/extension/vscode-node/services.ts index 543eaf6283..1767a89920 100644 --- a/src/extension/extension/vscode-node/services.ts +++ b/src/extension/extension/vscode-node/services.ts @@ -71,6 +71,7 @@ import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/d import { CommandServiceImpl, ICommandService } from '../../commands/node/commandService'; import { ApiEmbeddingsIndex, IApiEmbeddingsIndex } from '../../context/node/resolvers/extensionApi'; import { IPromptWorkspaceLabels, PromptWorkspaceLabels } from '../../context/node/resolvers/promptWorkspaceLabels'; +import { ISystemContextService, SystemContextService } from '../../context/node/systemContextService'; import { ChatAgentService } from '../../conversation/vscode-node/chatParticipants'; import { FeedbackReporter } from '../../conversation/vscode-node/feedbackReporter'; import { IUserFeedbackService, UserFeedbackService } from '../../conversation/vscode-node/userActions'; @@ -173,6 +174,7 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio builder.define(IScopeSelector, new SyncDescriptor(ScopeSelectorImpl)); builder.define(IGitDiffService, new SyncDescriptor(GitDiffService)); builder.define(IGitCommitMessageService, new SyncDescriptor(GitCommitMessageServiceImpl)); + builder.define(ISystemContextService, new SyncDescriptor(SystemContextService)); builder.define(IGithubRepositoryService, new SyncDescriptor(GithubRepositoryService)); builder.define(IDevContainerConfigurationService, new SyncDescriptor(DevContainerConfigurationServiceImpl)); builder.define(IChatAgentService, new SyncDescriptor(ChatAgentService)); diff --git a/src/extension/prompt/node/chatParticipantRequestHandler.ts b/src/extension/prompt/node/chatParticipantRequestHandler.ts index ba66ccfdb1..8090e168df 100644 --- a/src/extension/prompt/node/chatParticipantRequestHandler.ts +++ b/src/extension/prompt/node/chatParticipantRequestHandler.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as l10n from '@vscode/l10n'; -import type { ChatRequest, ChatRequestTurn2, ChatResponseStream, ChatResult, Location } from 'vscode'; +import type { ChatPromptReference, ChatRequest, ChatRequestTurn2, ChatResponseStream, ChatResult, Location } from 'vscode'; import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { IAuthenticationChatUpgradeService } from '../../../platform/authentication/common/authenticationUpgrade'; import { getChatParticipantIdFromName, getChatParticipantNameFromId, workspaceAgentName } from '../../../platform/chat/common/chatAgents'; @@ -31,6 +31,7 @@ import { ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatRe import { ICommandService } from '../../commands/node/commandService'; import { getAgentForIntent, Intent } from '../../common/constants'; import { IConversationStore } from '../../conversationStore/node/conversationStore'; +import { ISystemContextService } from '../../context/node/systemContextService'; import { IIntentService } from '../../intents/node/intentService'; import { UnknownIntent } from '../../intents/node/unknownIntent'; import { ContributedToolName } from '../../tools/common/toolNames'; @@ -44,6 +45,8 @@ import { IntentDetector } from './intentDetector'; import { CommandDetails } from './intentRegistry'; import { IIntent } from './intents'; +const SYSTEM_REFERENCE_PREFIX = 'vscode.prompt.file.system'; + export interface IChatAgentArgs { agentName: string; agentId: string; @@ -80,6 +83,7 @@ export class ChatParticipantRequestHandler { @IIgnoreService private readonly _ignoreService: IIgnoreService, @IIntentService private readonly _intentService: IIntentService, @IConversationStore private readonly _conversationStore: IConversationStore, + @ISystemContextService private readonly _systemContextService: ISystemContextService, @ITabsAndEditorsService tabsAndEditorsService: ITabsAndEditorsService, @ILogService private readonly _logService: ILogService, @IAuthenticationService private readonly _authService: IAuthenticationService, @@ -219,6 +223,10 @@ export class ChatParticipantRequestHandler { // sanitize the variables of all requests // this is done here because all intents must honor ignored files this.request = await this.sanitizeVariables(); + this.turn.request.message = this.request.prompt; + + + this.appendSystemContextReferences(); const command = this.chatAgentArgs.intentId ? this._commandService.getCommand(this.chatAgentArgs.intentId, this.location) : @@ -282,6 +290,53 @@ export class ChatParticipantRequestHandler { } } + private appendSystemContextReferences(): void { + const systemUris = this._systemContextService.getSystemPaths(); + if (!systemUris.length) { + return; + } + + const existingRefs = this.request.references ?? []; + + const newRefs: ChatPromptReference[] = []; + let counter = 0; + for (const uri of systemUris) { + const alreadyPresent = existingRefs.some(ref => this.matchesReference(ref, uri)) || newRefs.some(ref => this.matchesReference(ref, uri)); + if (!alreadyPresent) { + const id = `${SYSTEM_REFERENCE_PREFIX}.${counter++}`; + newRefs.push({ + id, + name: uri.fsPath, + value: uri, + modelDescription: `System context path ${uri.fsPath}`, + }); + } + } + + if (!newRefs.length) { + return; + } + + this.request = { + ...this.request, + references: [...existingRefs, ...newRefs] + } as ChatRequest; + + } + + private matchesReference(reference: ChatPromptReference, candidate: URI): boolean { + const value = reference.value; + if (URI.isUri(value)) { + return isEqual(value, candidate); + } + + if (isLocation(value)) { + return isEqual(value.uri, candidate); + } + + return false; + } + private async selectIntent(command: CommandDetails | undefined, history: Turn[]): Promise { if (!command?.intent && this.location === ChatLocation.Editor) { // TODO@jrieken do away with location specific code diff --git a/src/extension/tools/node/listDirTool.tsx b/src/extension/tools/node/listDirTool.tsx index d0d190e5b6..e327a6b294 100644 --- a/src/extension/tools/node/listDirTool.tsx +++ b/src/extension/tools/node/listDirTool.tsx @@ -14,6 +14,7 @@ import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { normalizePath } from '../../../util/vs/base/common/resources'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { LanguageModelPromptTsxPart, LanguageModelToolResult, MarkdownString } from '../../../vscodeTypes'; +import { ISystemContextService } from '../../context/node/systemContextService'; import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer'; import { ToolName } from '../common/toolNames'; import { ToolRegistry } from '../common/toolsRegistry'; @@ -31,12 +32,14 @@ class ListDirTool implements vscode.LanguageModelTool { @IInstantiationService private readonly instantiationService: IInstantiationService, @IWorkspaceService private readonly workspaceService: IWorkspaceService, @IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService, + @ISystemContextService private readonly systemContextService: ISystemContextService, ) { } async invoke(options: vscode.LanguageModelToolInvocationOptions, token: CancellationToken) { const uri = resolveToolInputPath(options.input.path, this.promptPathRepresentationService); - const relativeToWorkspace = this.workspaceService.getWorkspaceFolder(normalizePath(uri)); - if (!relativeToWorkspace) { + const workspaceFolder = this.workspaceService.getWorkspaceFolder(normalizePath(uri)); + const isSystemPath = this.systemContextService.isSystemPath(uri); + if (!workspaceFolder && !isSystemPath) { throw new Error(`Directory ${options.input.path} is outside of the workspace and can't be read`); } @@ -75,3 +78,4 @@ class ListDirResult extends PromptElement { ; } } + diff --git a/src/extension/tools/node/toolUtils.ts b/src/extension/tools/node/toolUtils.ts index 148da700ec..8861e10620 100644 --- a/src/extension/tools/node/toolUtils.ts +++ b/src/extension/tools/node/toolUtils.ts @@ -20,6 +20,7 @@ import { URI } from '../../../util/vs/base/common/uri'; import { IInstantiationService, ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation'; import { LanguageModelPromptTsxPart, LanguageModelToolResult, Location } from '../../../vscodeTypes'; import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer'; +import { ISystemContextService } from '../../context/node/systemContextService'; export function checkCancellation(token: CancellationToken): void { if (token.isCancellationRequested) { @@ -114,10 +115,13 @@ export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI): const tabsAndEditorsService = accessor.get(ITabsAndEditorsService); const promptPathRepresentationService = accessor.get(IPromptPathRepresentationService); const customInstructionsService = accessor.get(ICustomInstructionsService); + const systemContextService = accessor.get(ISystemContextService); await assertFileNotContentExcluded(accessor, uri); - if (!workspaceService.getWorkspaceFolder(normalizePath(uri)) && !customInstructionsService.isExternalInstructionsFile(uri) && uri.scheme !== Schemas.untitled) { + const isSystemPath = systemContextService.isSystemPath(uri); + + if (!isSystemPath && !workspaceService.getWorkspaceFolder(normalizePath(uri)) && !customInstructionsService.isExternalInstructionsFile(uri) && uri.scheme !== Schemas.untitled) { const fileOpenInSomeTab = tabsAndEditorsService.tabs.some(tab => isEqual(tab.uri, uri)); if (!fileOpenInSomeTab) { throw new Error(`File ${promptPathRepresentationService.getFilePath(uri)} is outside of the workspace, and not open in an editor, and can't be read`); From cd70f750abac9649be177bdab383fa77df131293 Mon Sep 17 00:00:00 2001 From: zamalali <87001156+zamalali@users.noreply.github.com> Date: Tue, 28 Oct 2025 23:57:55 +0100 Subject: [PATCH 2/5] feat(context): rename SystemContext to ExternalContext and add new service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements github.copilot.chat.addExternalContext command, refactors associated contributions and services, enforces 3-folder cap, and adds dedicated test coverage. Safe sync commit — local testing pending. --- ...xtService.ts => externalContextService.ts} | 0 .../node/test/externalContextService.spec.ts | 53 +++++++++++++++++++ ...tion.ts => externalContextContribution.ts} | 0 3 files changed, 53 insertions(+) rename src/extension/context/node/{systemContextService.ts => externalContextService.ts} (100%) create mode 100644 src/extension/context/node/test/externalContextService.spec.ts rename src/extension/context/vscode-node/{systemContextContribution.ts => externalContextContribution.ts} (100%) diff --git a/src/extension/context/node/systemContextService.ts b/src/extension/context/node/externalContextService.ts similarity index 100% rename from src/extension/context/node/systemContextService.ts rename to src/extension/context/node/externalContextService.ts diff --git a/src/extension/context/node/test/externalContextService.spec.ts b/src/extension/context/node/test/externalContextService.spec.ts new file mode 100644 index 0000000000..f88b0104d9 --- /dev/null +++ b/src/extension/context/node/test/externalContextService.spec.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; +import { URI } from '../../../../util/vs/base/common/uri'; +import { ExternalContextService } from '../externalContextService'; + +function createUri(name: string): URI { + return URI.file(path.join(process.cwd(), 'external-context-tests', name)); +} + +describe('ExternalContextService', () => { + it('caps at max external paths', () => { + const service = new ExternalContextService(); + + service.addExternalPaths([ + createUri('one'), + createUri('two'), + createUri('three'), + createUri('four') + ]); + + expect(service.getExternalPaths()).toHaveLength(service.maxExternalPaths); + }); + + it('fires change event when paths are added', () => { + const service = new ExternalContextService(); + let fired = 0; + + service.onDidChangeExternalContext(() => fired++); + + service.addExternalPaths([createUri('one')]); + + expect(fired).toBe(1); + }); + + it('removes paths and fires event', () => { + const service = new ExternalContextService(); + const [added] = service.addExternalPaths([createUri('one')]); + let fired = 0; + + service.onDidChangeExternalContext(() => fired++); + + service.removeExternalPath(added); + + expect(service.getExternalPaths()).toHaveLength(0); + expect(fired).toBe(1); + }); +}); + diff --git a/src/extension/context/vscode-node/systemContextContribution.ts b/src/extension/context/vscode-node/externalContextContribution.ts similarity index 100% rename from src/extension/context/vscode-node/systemContextContribution.ts rename to src/extension/context/vscode-node/externalContextContribution.ts From e25cdd84d7909f010ff0e2523ec1f8e1be6211f6 Mon Sep 17 00:00:00 2001 From: zamalali <87001156+zamalali@users.noreply.github.com> Date: Wed, 29 Oct 2025 13:36:53 +0100 Subject: [PATCH 3/5] feat(context): finalize ExternalContext service integration and cap logic Aligns naming, command wiring, and prompt/tool references with ExternalContext design. Enforces 3-folder cap via IExternalContextService, adds exclusion guardrails and status-bar badge. Includes package and i18n updates. --- package.json | 10 +- package.nls.json | 3 +- .../context/node/externalContextService.ts | 62 +++-- .../externalContextContribution.ts | 236 +++++++++++++----- .../extension/vscode-node/contributions.ts | 4 +- .../extension/vscode-node/services.ts | 4 +- .../node/chatParticipantRequestHandler.ts | 20 +- src/extension/tools/node/listDirTool.tsx | 9 +- src/extension/tools/node/toolUtils.ts | 8 +- 9 files changed, 243 insertions(+), 113 deletions(-) diff --git a/package.json b/package.json index cefb7f17e0..9c6c3918c0 100644 --- a/package.json +++ b/package.json @@ -1814,11 +1814,17 @@ "category": "Copilot CLI" }, { - "command": "github.copilot.chat.addSystemContext", - "title": "%github.copilot.command.addSystemContext%", + "command": "github.copilot.chat.addExternalContext", + "title": "%github.copilot.command.addExternalContext%", "category": "Chat", "icon": "$(folder-library)" }, + { + "command": "github.copilot.chat.manageExternalContexts", + "title": "%github.copilot.command.manageExternalContexts%", + "category": "Chat", + "icon": "$(list-selection)" + }, { "command": "github.copilot.chat.replay", "title": "Start Chat Replay", diff --git a/package.nls.json b/package.nls.json index 0630a17cc8..caf76c2ee1 100644 --- a/package.nls.json +++ b/package.nls.json @@ -29,7 +29,8 @@ "github.copilot.command.generateTests": "Generate Tests", "github.copilot.command.openUserPreferences": "Open User Preferences", "github.copilot.command.sendChatFeedback": "Send Chat Feedback", - "github.copilot.command.addSystemContext": "Add System Context", + "github.copilot.command.addExternalContext": "Add External Folder", + "github.copilot.command.manageExternalContexts": "Manage External Folders", "github.copilot.command.buildLocalWorkspaceIndex": "Build Local Workspace Index", "github.copilot.command.buildRemoteWorkspaceIndex": "Build Remote Workspace Index", "github.copilot.viewsWelcome.signIn": { diff --git a/src/extension/context/node/externalContextService.ts b/src/extension/context/node/externalContextService.ts index 250c840397..2aee3d4e2f 100644 --- a/src/extension/context/node/externalContextService.ts +++ b/src/extension/context/node/externalContextService.ts @@ -11,58 +11,72 @@ import { Emitter, Event } from '../../../util/vs/base/common/event'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { isWindows } from '../../../util/vs/base/common/platform'; -export const ISystemContextService = createServiceIdentifier('ISystemContextService'); +const MAX_EXTERNAL_PATHS = 3; -export interface ISystemContextService { +export const IExternalContextService = createServiceIdentifier('IExternalContextService'); + +export interface IExternalContextService { readonly _serviceBrand: undefined; - readonly onDidChangeSystemContext: Event; - getSystemPaths(): readonly URI[]; - addSystemPaths(paths: readonly URI[]): void; - replaceSystemPaths(paths: readonly URI[]): void; - removeSystemPath(path: URI): void; + readonly onDidChangeExternalContext: Event; + readonly maxExternalPaths: number; + getExternalPaths(): readonly URI[]; + addExternalPaths(paths: readonly URI[]): readonly URI[]; + replaceExternalPaths(paths: readonly URI[]): void; + removeExternalPath(path: URI): void; clear(): void; - isSystemPath(uri: URI): boolean; + isExternalPath(uri: URI): boolean; } -export class SystemContextService extends Disposable implements ISystemContextService { +export class ExternalContextService extends Disposable implements IExternalContextService { declare readonly _serviceBrand: undefined; - private readonly _onDidChangeSystemContext = this._register(new Emitter()); - readonly onDidChangeSystemContext = this._onDidChangeSystemContext.event; + private readonly _onDidChangeExternalContext = this._register(new Emitter()); + readonly onDidChangeExternalContext: Event = this._onDidChangeExternalContext.event; + + readonly maxExternalPaths = MAX_EXTERNAL_PATHS; private readonly _paths = new Map(); - getSystemPaths(): readonly URI[] { - return Array.from(this._paths.values()); + getExternalPaths(): readonly URI[] { + return [...this._paths.values()]; } - addSystemPaths(paths: readonly URI[]): void { - let didChange = false; + addExternalPaths(paths: readonly URI[]): readonly URI[] { + const added: URI[] = []; + if (!paths.length) { + return added; + } + for (const path of paths) { + if (this._paths.size >= MAX_EXTERNAL_PATHS) { + break; + } const key = path.toString(); if (!this._paths.has(key)) { this._paths.set(key, path); - didChange = true; + added.push(path); } } - if (didChange) { - this._onDidChangeSystemContext.fire(); + if (added.length) { + this._onDidChangeExternalContext.fire(); } + + return added; } - replaceSystemPaths(paths: readonly URI[]): void { + replaceExternalPaths(paths: readonly URI[]): void { this._paths.clear(); for (const path of paths) { this._paths.set(path.toString(), path); } - this._onDidChangeSystemContext.fire(); + this._onDidChangeExternalContext.fire(); } - removeSystemPath(path: URI): void { + removeExternalPath(path: URI): void { for (const [key, storedPath] of this._paths) { if (isEqual(storedPath, path)) { this._paths.delete(key); - this._onDidChangeSystemContext.fire(); + this._onDidChangeExternalContext.fire(); return; } } @@ -73,10 +87,10 @@ export class SystemContextService extends Disposable implements ISystemContextSe return; } this._paths.clear(); - this._onDidChangeSystemContext.fire(); + this._onDidChangeExternalContext.fire(); } - isSystemPath(uri: URI): boolean { + isExternalPath(uri: URI): boolean { const candidateComparable = this.toComparablePath(uri); for (const stored of this._paths.values()) { const storedComparable = this.toComparablePath(stored); diff --git a/src/extension/context/vscode-node/externalContextContribution.ts b/src/extension/context/vscode-node/externalContextContribution.ts index c1bb5100ec..985428f035 100644 --- a/src/extension/context/vscode-node/externalContextContribution.ts +++ b/src/extension/context/vscode-node/externalContextContribution.ts @@ -1,50 +1,132 @@ -/*--------------------------------------------------------------------------------------------- +/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as path from 'path'; import * as vscode from 'vscode'; -import { isMacintosh, isWindows } from '../../../util/vs/base/common/platform'; +import { minimatch } from 'minimatch'; import { URI } from '../../../util/vs/base/common/uri'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { IExtensionContribution } from '../../common/contributions'; -import { ISystemContextService } from '../node/systemContextService'; +import { IExternalContextService } from '../node/externalContextService'; -interface SystemContextQuickPickItem extends vscode.QuickPickItem { +interface ExternalContextQuickPickItem extends vscode.QuickPickItem { readonly uri?: URI; readonly isBrowse?: boolean; } -const COMMAND_ID = 'github.copilot.chat.addSystemContext'; +interface PickResult { + readonly accepted: URI[]; + readonly excluded: URI[]; +} + +const ADD_COMMAND_ID = 'github.copilot.chat.addExternalContext'; +const MANAGE_COMMAND_ID = 'github.copilot.chat.manageExternalContexts'; +const STATUS_ALIGNMENT = vscode.StatusBarAlignment.Right; +const STATUS_PRIORITY = 100; +const DISALLOWED_FOLDER_NAMES = new Set(['node_modules', '.git', 'dist', 'build']); +const DISALLOWED_EXTENSIONS = new Set(['.log', '.tmp']); + +export class ExternalContextContribution extends Disposable implements IExtensionContribution { + readonly id = 'externalContext.contribution'; -export class SystemContextContribution extends Disposable implements IExtensionContribution { - readonly id = 'systemContext.contribution'; + private readonly statusItem: vscode.StatusBarItem; constructor( - @ISystemContextService private readonly systemContextService: ISystemContextService, + @IExternalContextService private readonly externalContextService: IExternalContextService, ) { super(); - this._register(vscode.commands.registerCommand(COMMAND_ID, async () => { - const selected = await this.pickSystemPaths(); - if (!selected || selected.length === 0) { - return; + this.statusItem = this._register(vscode.window.createStatusBarItem(STATUS_ALIGNMENT, STATUS_PRIORITY)); + this.statusItem.name = vscode.l10n.t('Copilot External Context'); + this.statusItem.command = MANAGE_COMMAND_ID; + this.statusItem.tooltip = vscode.l10n.t('Manage external folders shared with Copilot'); + this.statusItem.show(); + this.updateStatus(); + + this._register(vscode.commands.registerCommand(ADD_COMMAND_ID, async () => this.handleAddExternalContext())); + this._register(vscode.commands.registerCommand(MANAGE_COMMAND_ID, async () => this.handleManageExternalContexts())); + this._register(this.externalContextService.onDidChangeExternalContext(() => this.updateStatus())); + } + + private updateStatus(): void { + const count = this.externalContextService.getExternalPaths().length; + const max = this.externalContextService.maxExternalPaths; + this.statusItem.text = `$(folder) ${count}/${max}`; + } + + private async handleAddExternalContext(): Promise { + const remaining = this.externalContextService.maxExternalPaths - this.externalContextService.getExternalPaths().length; + if (remaining <= 0) { + void vscode.window.showWarningMessage(vscode.l10n.t('Maximum of {0} external folders reached.', this.externalContextService.maxExternalPaths.toString())); + return; + } + + const pick = await this.pickExternalPaths(); + if (!pick || pick.accepted.length === 0) { + if (pick && pick.excluded.length) { + this.showExclusionMessage(pick.excluded); } + return; + } + + const added = this.externalContextService.addExternalPaths(pick.accepted); + if (!added.length) { + void vscode.window.showWarningMessage(vscode.l10n.t('No external folders were added. The maximum of {0} folders may already be reached or your selection was excluded.', this.externalContextService.maxExternalPaths.toString())); + return; + } + + const label = added.length === 1 ? added[0].fsPath : vscode.l10n.t('{0} folders', added.length.toString()); + void vscode.window.setStatusBarMessage(vscode.l10n.t('Added {0} to Copilot external context', label), 3000); + + if (pick.excluded.length) { + this.showExclusionMessage(pick.excluded); + } + + if (added.length < pick.accepted.length || this.externalContextService.getExternalPaths().length >= this.externalContextService.maxExternalPaths) { + void vscode.window.showWarningMessage(vscode.l10n.t('Maximum of {0} external folders reached.', this.externalContextService.maxExternalPaths.toString())); + } + } - this.systemContextService.addSystemPaths(selected); + private async handleManageExternalContexts(): Promise { + const current = this.externalContextService.getExternalPaths(); + if (!current.length) { + void vscode.window.showInformationMessage(vscode.l10n.t('No external folders are currently shared with Copilot.')); + return; + } - const label = selected.length === 1 ? selected[0].fsPath : vscode.l10n.t('{0} system locations', selected.length.toString()); - const statusMessage = vscode.l10n.t('Added {0} to Copilot context', label); - void vscode.window.setStatusBarMessage(statusMessage, 3000); + const items = current.map(uri => ({ + label: uri.fsPath, + description: vscode.l10n.t('Remove from Copilot external context'), + uri })); + + const picked = await vscode.window.showQuickPick(items, { + canPickMany: true, + ignoreFocusOut: true, + placeHolder: vscode.l10n.t('Select folders to remove from Copilot external context') + }); + + if (!picked || picked.length === 0) { + return; + } + + for (const item of picked) { + if (item.uri) { + this.externalContextService.removeExternalPath(item.uri); + } + } + + const removedLabel = picked.length === 1 && picked[0].uri ? picked[0].uri.fsPath : vscode.l10n.t('{0} folders', picked.length.toString()); + void vscode.window.setStatusBarMessage(vscode.l10n.t('Removed {0} from Copilot external context', removedLabel), 3000); } - private async pickSystemPaths(): Promise { + private async pickExternalPaths(): Promise { const items = await this.getQuickPickItems(); - const picked = await vscode.window.showQuickPick(items, { - placeHolder: vscode.l10n.t('Select system locations to include in Copilot context'), + const picked = await vscode.window.showQuickPick(items, { + placeHolder: vscode.l10n.t('Select folders to include in Copilot external context'), ignoreFocusOut: true, canPickMany: true, }); @@ -53,10 +135,10 @@ export class SystemContextContribution extends Disposable implements IExtensionC return undefined; } - const selectedUris: URI[] = []; + const selected: URI[] = []; for (const item of picked) { if (item.uri) { - selectedUris.push(item.uri); + selected.push(item.uri); } } @@ -65,81 +147,54 @@ export class SystemContextContribution extends Disposable implements IExtensionC canSelectFiles: false, canSelectFolders: true, canSelectMany: true, - openLabel: vscode.l10n.t('Add to Copilot System Context') + openLabel: vscode.l10n.t('Add to Copilot external context') }); if (browseUris) { for (const uri of browseUris) { - selectedUris.push(URI.revive(uri)); + selected.push(URI.revive(uri)); } } } - if (selectedUris.length === 0) { - return undefined; + if (!selected.length) { + return { accepted: [], excluded: [] }; } - return this.normalizeAndDeduplicate(selectedUris); + const normalized = this.normalizeAndDeduplicate(selected); + return this.filterExcluded(normalized); } - private async getQuickPickItems(): Promise { - const items: SystemContextQuickPickItem[] = []; - const defaults = this.getDefaultSystemPaths(); + private async getQuickPickItems(): Promise { + const items: ExternalContextQuickPickItem[] = []; + const envPaths = this.getEnvConfiguredPaths(); - for (const uri of defaults) { + for (const uri of envPaths) { items.push({ label: uri.fsPath, - description: vscode.l10n.t('Suggested system location'), + description: vscode.l10n.t('From EXTERNAL_CONTEXT_PATHS setting'), uri }); } - const envPaths = this.getEnvConfiguredPaths(); - for (const uri of envPaths) { - if (!defaults.some(def => def.toString() === uri.toString())) { - items.push({ - label: uri.fsPath, - description: vscode.l10n.t('From SYSTEM_CONTEXT_PATHS setting'), - uri - }); - } - } - items.sort((a, b) => a.label.localeCompare(b.label)); items.push({ - label: vscode.l10n.t('Browse for folder…'), - description: vscode.l10n.t('Select a custom system folder'), + label: vscode.l10n.t('Browse for folder…'), + description: vscode.l10n.t('Select a custom folder'), isBrowse: true }); return items; } - private getDefaultSystemPaths(): URI[] { - const defaults: string[] = []; - - if (isWindows) { - defaults.push('C:\\Windows\\System32', 'C:\\ProgramData'); - } else if (isMacintosh) { - defaults.push('/System/Library', '/etc'); - } else { - defaults.push('/etc', '/var/log'); - } - - return defaults - .filter(p => !!p) - .map(p => URI.file(p)); - } - private getEnvConfiguredPaths(): URI[] { - const envValue = process.env.SYSTEM_CONTEXT_PATHS; + const envValue = process.env.EXTERNAL_CONTEXT_PATHS || process.env.SYSTEM_CONTEXT_PATHS; if (!envValue) { return []; } - const delimiter = isWindows ? ';' : ':'; - const segments = envValue.split(delimiter) + const segments = envValue.split(path.delimiter) .map(segment => segment.trim()) .filter(segment => segment.length > 0); @@ -153,4 +208,59 @@ export class SystemContextContribution extends Disposable implements IExtensionC } return Array.from(unique.values()); } + + private async filterExcluded(uris: readonly URI[]): Promise { + const patterns = await this.getWorkspaceExcludePatterns(); + const accepted: URI[] = []; + const excluded: URI[] = []; + + for (const uri of uris) { + if (this.shouldExclude(uri, patterns)) { + excluded.push(uri); + } else { + accepted.push(uri); + } + } + + return { accepted, excluded }; + } + + private async getWorkspaceExcludePatterns(): Promise { + const config = vscode.workspace.getConfiguration('files'); + const excludes = config.get>('exclude'); + if (!excludes) { + return []; + } + + return Object.entries(excludes) + .filter(([, value]) => value === true) + .map(([pattern]) => pattern); + } + + private shouldExclude(uri: URI, excludePatterns: readonly string[]): boolean { + const fsPath = uri.fsPath; + const segments = fsPath.split(path.sep).map(segment => segment.toLowerCase()); + if (segments.some(segment => DISALLOWED_FOLDER_NAMES.has(segment))) { + return true; + } + + const ext = path.extname(fsPath).toLowerCase(); + if (DISALLOWED_EXTENSIONS.has(ext)) { + return true; + } + + const normalized = fsPath.replace(/\\+/g, '/'); + for (const pattern of excludePatterns) { + if (minimatch(normalized, pattern, { dot: true })) { + return true; + } + } + + return false; + } + + private showExclusionMessage(excluded: readonly URI[]): void { + const label = excluded.length === 1 ? excluded[0].fsPath : vscode.l10n.t('{0} folders', excluded.length.toString()); + void vscode.window.showWarningMessage(vscode.l10n.t('Skipped {0} because the folder is excluded or disallowed for Copilot external context.', label)); + } } diff --git a/src/extension/extension/vscode-node/contributions.ts b/src/extension/extension/vscode-node/contributions.ts index aad0a5643b..824d417155 100644 --- a/src/extension/extension/vscode-node/contributions.ts +++ b/src/extension/extension/vscode-node/contributions.ts @@ -19,7 +19,7 @@ import { ConversationFeature } from '../../conversation/vscode-node/conversation import { FeedbackCommandContribution } from '../../conversation/vscode-node/feedbackContribution'; import { LanguageModelAccess } from '../../conversation/vscode-node/languageModelAccess'; import { LogWorkspaceStateContribution } from '../../conversation/vscode-node/logWorkspaceState'; -import { SystemContextContribution } from '../../context/vscode-node/systemContextContribution'; +import { ExternalContextContribution } from '../../context/vscode-node/externalContextContribution'; import { RemoteAgentContribution } from '../../conversation/vscode-node/remoteAgents'; import { LanguageModelProxyContrib } from '../../externalAgents/vscode-node/lmProxyContrib'; import { WalkthroughCommandContribution } from '../../getting-started/vscode-node/commands'; @@ -71,7 +71,7 @@ export const vscodeNodeContributions: IExtensionContributionFactory[] = [ asContributionFactory(CopilotDebugCommandContribution), asContributionFactory(DebugCommandsContribution), asContributionFactory(LanguageModelAccess), - asContributionFactory(SystemContextContribution), + asContributionFactory(ExternalContextContribution), asContributionFactory(WalkthroughCommandContribution), asContributionFactory(InlineEditProviderFeature), asContributionFactory(SettingsSchemaFeature), diff --git a/src/extension/extension/vscode-node/services.ts b/src/extension/extension/vscode-node/services.ts index 1767a89920..1215238636 100644 --- a/src/extension/extension/vscode-node/services.ts +++ b/src/extension/extension/vscode-node/services.ts @@ -71,7 +71,7 @@ import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/d import { CommandServiceImpl, ICommandService } from '../../commands/node/commandService'; import { ApiEmbeddingsIndex, IApiEmbeddingsIndex } from '../../context/node/resolvers/extensionApi'; import { IPromptWorkspaceLabels, PromptWorkspaceLabels } from '../../context/node/resolvers/promptWorkspaceLabels'; -import { ISystemContextService, SystemContextService } from '../../context/node/systemContextService'; +import { IExternalContextService, ExternalContextService } from '../../context/node/externalContextService'; import { ChatAgentService } from '../../conversation/vscode-node/chatParticipants'; import { FeedbackReporter } from '../../conversation/vscode-node/feedbackReporter'; import { IUserFeedbackService, UserFeedbackService } from '../../conversation/vscode-node/userActions'; @@ -174,7 +174,7 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio builder.define(IScopeSelector, new SyncDescriptor(ScopeSelectorImpl)); builder.define(IGitDiffService, new SyncDescriptor(GitDiffService)); builder.define(IGitCommitMessageService, new SyncDescriptor(GitCommitMessageServiceImpl)); - builder.define(ISystemContextService, new SyncDescriptor(SystemContextService)); + builder.define(IExternalContextService, new SyncDescriptor(ExternalContextService)); builder.define(IGithubRepositoryService, new SyncDescriptor(GithubRepositoryService)); builder.define(IDevContainerConfigurationService, new SyncDescriptor(DevContainerConfigurationServiceImpl)); builder.define(IChatAgentService, new SyncDescriptor(ChatAgentService)); diff --git a/src/extension/prompt/node/chatParticipantRequestHandler.ts b/src/extension/prompt/node/chatParticipantRequestHandler.ts index 8090e168df..3a17a811f8 100644 --- a/src/extension/prompt/node/chatParticipantRequestHandler.ts +++ b/src/extension/prompt/node/chatParticipantRequestHandler.ts @@ -31,7 +31,7 @@ import { ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatRe import { ICommandService } from '../../commands/node/commandService'; import { getAgentForIntent, Intent } from '../../common/constants'; import { IConversationStore } from '../../conversationStore/node/conversationStore'; -import { ISystemContextService } from '../../context/node/systemContextService'; +import { IExternalContextService } from '../../context/node/externalContextService'; import { IIntentService } from '../../intents/node/intentService'; import { UnknownIntent } from '../../intents/node/unknownIntent'; import { ContributedToolName } from '../../tools/common/toolNames'; @@ -45,7 +45,7 @@ import { IntentDetector } from './intentDetector'; import { CommandDetails } from './intentRegistry'; import { IIntent } from './intents'; -const SYSTEM_REFERENCE_PREFIX = 'vscode.prompt.file.system'; +const EXTERNAL_CONTEXT_REFERENCE_PREFIX = 'vscode.prompt.file.external'; export interface IChatAgentArgs { agentName: string; @@ -83,7 +83,7 @@ export class ChatParticipantRequestHandler { @IIgnoreService private readonly _ignoreService: IIgnoreService, @IIntentService private readonly _intentService: IIntentService, @IConversationStore private readonly _conversationStore: IConversationStore, - @ISystemContextService private readonly _systemContextService: ISystemContextService, + @IExternalContextService private readonly _externalContextService: IExternalContextService, @ITabsAndEditorsService tabsAndEditorsService: ITabsAndEditorsService, @ILogService private readonly _logService: ILogService, @IAuthenticationService private readonly _authService: IAuthenticationService, @@ -226,7 +226,7 @@ export class ChatParticipantRequestHandler { this.turn.request.message = this.request.prompt; - this.appendSystemContextReferences(); + this.appendExternalContextReferences(); const command = this.chatAgentArgs.intentId ? this._commandService.getCommand(this.chatAgentArgs.intentId, this.location) : @@ -290,9 +290,9 @@ export class ChatParticipantRequestHandler { } } - private appendSystemContextReferences(): void { - const systemUris = this._systemContextService.getSystemPaths(); - if (!systemUris.length) { + private appendExternalContextReferences(): void { + const externalUris = this._externalContextService.getExternalPaths(); + if (!externalUris.length) { return; } @@ -300,15 +300,15 @@ export class ChatParticipantRequestHandler { const newRefs: ChatPromptReference[] = []; let counter = 0; - for (const uri of systemUris) { + for (const uri of externalUris) { const alreadyPresent = existingRefs.some(ref => this.matchesReference(ref, uri)) || newRefs.some(ref => this.matchesReference(ref, uri)); if (!alreadyPresent) { - const id = `${SYSTEM_REFERENCE_PREFIX}.${counter++}`; + const id = `${EXTERNAL_CONTEXT_REFERENCE_PREFIX}.${counter++}`; newRefs.push({ id, name: uri.fsPath, value: uri, - modelDescription: `System context path ${uri.fsPath}`, + modelDescription: `External context path ${uri.fsPath}`, }); } } diff --git a/src/extension/tools/node/listDirTool.tsx b/src/extension/tools/node/listDirTool.tsx index e327a6b294..2f9579b57d 100644 --- a/src/extension/tools/node/listDirTool.tsx +++ b/src/extension/tools/node/listDirTool.tsx @@ -14,7 +14,7 @@ import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { normalizePath } from '../../../util/vs/base/common/resources'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { LanguageModelPromptTsxPart, LanguageModelToolResult, MarkdownString } from '../../../vscodeTypes'; -import { ISystemContextService } from '../../context/node/systemContextService'; +import { IExternalContextService } from '../../context/node/externalContextService'; import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer'; import { ToolName } from '../common/toolNames'; import { ToolRegistry } from '../common/toolsRegistry'; @@ -32,14 +32,14 @@ class ListDirTool implements vscode.LanguageModelTool { @IInstantiationService private readonly instantiationService: IInstantiationService, @IWorkspaceService private readonly workspaceService: IWorkspaceService, @IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService, - @ISystemContextService private readonly systemContextService: ISystemContextService, + @IExternalContextService private readonly externalContextService: IExternalContextService, ) { } async invoke(options: vscode.LanguageModelToolInvocationOptions, token: CancellationToken) { const uri = resolveToolInputPath(options.input.path, this.promptPathRepresentationService); const workspaceFolder = this.workspaceService.getWorkspaceFolder(normalizePath(uri)); - const isSystemPath = this.systemContextService.isSystemPath(uri); - if (!workspaceFolder && !isSystemPath) { + const isExternalPath = this.externalContextService.isExternalPath(uri); + if (!workspaceFolder && !isExternalPath) { throw new Error(`Directory ${options.input.path} is outside of the workspace and can't be read`); } @@ -78,4 +78,3 @@ class ListDirResult extends PromptElement { ; } } - diff --git a/src/extension/tools/node/toolUtils.ts b/src/extension/tools/node/toolUtils.ts index 8861e10620..d6880ee5e5 100644 --- a/src/extension/tools/node/toolUtils.ts +++ b/src/extension/tools/node/toolUtils.ts @@ -20,7 +20,7 @@ import { URI } from '../../../util/vs/base/common/uri'; import { IInstantiationService, ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation'; import { LanguageModelPromptTsxPart, LanguageModelToolResult, Location } from '../../../vscodeTypes'; import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer'; -import { ISystemContextService } from '../../context/node/systemContextService'; +import { IExternalContextService } from '../../context/node/externalContextService'; export function checkCancellation(token: CancellationToken): void { if (token.isCancellationRequested) { @@ -115,13 +115,13 @@ export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI): const tabsAndEditorsService = accessor.get(ITabsAndEditorsService); const promptPathRepresentationService = accessor.get(IPromptPathRepresentationService); const customInstructionsService = accessor.get(ICustomInstructionsService); - const systemContextService = accessor.get(ISystemContextService); + const externalContextService = accessor.get(IExternalContextService); await assertFileNotContentExcluded(accessor, uri); - const isSystemPath = systemContextService.isSystemPath(uri); + const isExternalPath = externalContextService.isExternalPath(uri); - if (!isSystemPath && !workspaceService.getWorkspaceFolder(normalizePath(uri)) && !customInstructionsService.isExternalInstructionsFile(uri) && uri.scheme !== Schemas.untitled) { + if (!isExternalPath && !workspaceService.getWorkspaceFolder(normalizePath(uri)) && !customInstructionsService.isExternalInstructionsFile(uri) && uri.scheme !== Schemas.untitled) { const fileOpenInSomeTab = tabsAndEditorsService.tabs.some(tab => isEqual(tab.uri, uri)); if (!fileOpenInSomeTab) { throw new Error(`File ${promptPathRepresentationService.getFilePath(uri)} is outside of the workspace, and not open in an editor, and can't be read`); From 85aad2c5e1f15ef27100466dfff4f79696fbcb3c Mon Sep 17 00:00:00 2001 From: zamal_ <87001156+zamalali@users.noreply.github.com> Date: Wed, 29 Oct 2025 14:18:35 +0100 Subject: [PATCH 4/5] Update package.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 1803e2bada..511b2ef29a 100644 --- a/package.json +++ b/package.json @@ -3489,7 +3489,6 @@ "when": "view == workbench.panel.chat.view.copilot", "group": "3_show" }, - { "command": "github.copilot.cloud.sessions.refresh", "when": "view == workbench.view.chat.sessions.copilot-cloud-agent", From 70de36a77f734db1934bf43c175979080e0cae75 Mon Sep 17 00:00:00 2001 From: zamalali <87001156+zamalali@users.noreply.github.com> Date: Wed, 29 Oct 2025 14:28:11 +0100 Subject: [PATCH 5/5] fix: enforce folder cap, remove redundant checks, and streamline external context reference handling --- src/extension/context/node/externalContextService.ts | 12 +++++++----- .../prompt/node/chatParticipantRequestHandler.ts | 3 +-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/extension/context/node/externalContextService.ts b/src/extension/context/node/externalContextService.ts index 2aee3d4e2f..be6d938cdf 100644 --- a/src/extension/context/node/externalContextService.ts +++ b/src/extension/context/node/externalContextService.ts @@ -67,7 +67,13 @@ export class ExternalContextService extends Disposable implements IExternalConte replaceExternalPaths(paths: readonly URI[]): void { this._paths.clear(); for (const path of paths) { - this._paths.set(path.toString(), path); + if (this._paths.size >= MAX_EXTERNAL_PATHS) { + break; + } + const key = path.toString(); + if (!this._paths.has(key)) { + this._paths.set(key, path); + } } this._onDidChangeExternalContext.fire(); } @@ -112,10 +118,6 @@ export class ExternalContextService extends Disposable implements IExternalConte } private isSubPath(child: string, potentialParent: string): boolean { - if (potentialParent === child) { - return true; - } - const parentWithSep = potentialParent.endsWith(npath.sep) ? potentialParent : potentialParent + npath.sep; return child.startsWith(parentWithSep); } diff --git a/src/extension/prompt/node/chatParticipantRequestHandler.ts b/src/extension/prompt/node/chatParticipantRequestHandler.ts index 3a17a811f8..9cb3b0cf04 100644 --- a/src/extension/prompt/node/chatParticipantRequestHandler.ts +++ b/src/extension/prompt/node/chatParticipantRequestHandler.ts @@ -225,7 +225,6 @@ export class ChatParticipantRequestHandler { this.request = await this.sanitizeVariables(); this.turn.request.message = this.request.prompt; - this.appendExternalContextReferences(); const command = this.chatAgentArgs.intentId ? @@ -301,7 +300,7 @@ export class ChatParticipantRequestHandler { const newRefs: ChatPromptReference[] = []; let counter = 0; for (const uri of externalUris) { - const alreadyPresent = existingRefs.some(ref => this.matchesReference(ref, uri)) || newRefs.some(ref => this.matchesReference(ref, uri)); + const alreadyPresent = existingRefs.some(ref => this.matchesReference(ref, uri)); if (!alreadyPresent) { const id = `${EXTERNAL_CONTEXT_REFERENCE_PREFIX}.${counter++}`; newRefs.push({