From 05c2a10e30491a55aef50833c19c5d1f54034c8d Mon Sep 17 00:00:00 2001 From: eleanorjboyd Date: Tue, 21 Jan 2025 13:15:11 -0800 Subject: [PATCH 01/11] add pkg copilot tool --- package-lock.json | 12 +++--- package.json | 24 ++++++++++- src/copilotTools.test.ts | 92 ++++++++++++++++++++++++++++++++++++++++ src/copilotTools.ts | 90 +++++++++++++++++++++++++++++++++++++++ src/extension.ts | 3 ++ 5 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 src/copilotTools.test.ts create mode 100644 src/copilotTools.ts diff --git a/package-lock.json b/package-lock.json index 8058ece5..a68e7dcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -715,9 +715,9 @@ "dev": true }, "node_modules/@types/vscode": { - "version": "1.93.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.93.0.tgz", - "integrity": "sha512-kUK6jAHSR5zY8ps42xuW89NLcBpw1kOabah7yv38J8MyiYuOHxLQBi0e7zeXbQgVefDy/mZZetqEFC+Fl5eIEQ==", + "version": "1.96.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.96.0.tgz", + "integrity": "sha512-qvZbSZo+K4ZYmmDuaodMbAa67Pl6VDQzLKFka6rq+3WUTY4Kro7Bwoi0CuZLO/wema0ygcmpwow7zZfPJTs5jg==", "dev": true }, "node_modules/@types/which": { @@ -5995,9 +5995,9 @@ "dev": true }, "@types/vscode": { - "version": "1.93.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.93.0.tgz", - "integrity": "sha512-kUK6jAHSR5zY8ps42xuW89NLcBpw1kOabah7yv38J8MyiYuOHxLQBi0e7zeXbQgVefDy/mZZetqEFC+Fl5eIEQ==", + "version": "1.96.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.96.0.tgz", + "integrity": "sha512-qvZbSZo+K4ZYmmDuaodMbAa67Pl6VDQzLKFka6rq+3WUTY4Kro7Bwoi0CuZLO/wema0ygcmpwow7zZfPJTs5jg==", "dev": true }, "@types/which": { diff --git a/package.json b/package.json index 5c73335d..e3366969 100644 --- a/package.json +++ b/package.json @@ -424,6 +424,28 @@ { "type": "python" } + ], + "languageModelTools": [ + { + "name": "python_get_python_packages", + "displayName": "Get Python Packages", + "modelDescription": "Given a file path, finds the workspace and selected python environment for this file, and returns the list of installed packages.", + "toolReferenceName": "pythonGetPythonPackages", + "tags": [], + "icon": "$(files)", + "inputSchema": { + "type": "object", + "properties": { + "filePath": { + "type": "string" + } + }, + "required": [ + "filePath" + ] + }, + "canBeReferencedInPrompt": true + } ] }, "scripts": { @@ -472,4 +494,4 @@ "vscode-jsonrpc": "^9.0.0-next.5", "which": "^4.0.0" } -} +} \ No newline at end of file diff --git a/src/copilotTools.test.ts b/src/copilotTools.test.ts new file mode 100644 index 00000000..be73333c --- /dev/null +++ b/src/copilotTools.test.ts @@ -0,0 +1,92 @@ +// import * as assert from 'assert'; +// import * as vscode from 'vscode'; +// import { GetPackagesTool } from './copilotTools'; +// import { Package, PackageId, PackageInfo, PythonEnvironmentApi } from './api'; +// import { IGetActiveFile } from './copilotTools'; +// import * as sinon from 'sinon'; + +// suite('GetPackagesTool Tests', () => { +// let tool: GetPackagesTool; +// let mockApi: sinon.SinonStubbedInstance; + +// setup(() => { +// tool = new GetPackagesTool(); +// // Create a stub instance of the PythonEnvironmentApi interface +// mockApi = sinon.createStubInstance({} as any); +// }); + +// test('should throw error if filePath is undefined', async () => { +// const testFile: IGetActiveFile = { +// filePath: '', +// }; +// const options = { input: testFile, toolInvocationToken: undefined }; +// const token = new vscode.CancellationTokenSource().token; +// await assert.rejects(tool.invoke(options, token), { message: 'Invalid input: filePath is required' }); +// }); + +// test('should throw error for notebook files', async () => { +// const testFile: IGetActiveFile = { +// filePath: 'test.ipynb', +// }; +// const options = { input: testFile, toolInvocationToken: undefined }; +// const token = new vscode.CancellationTokenSource().token; +// await assert.rejects(tool.invoke(options, token), { +// message: 'Unable to access Jupyter kernels for notebook cells', +// }); +// }); + +// test('should throw error for notebook cells', async () => { +// const testFile: IGetActiveFile = { +// filePath: 'test.ipynb#cell', +// }; +// const options = { input: testFile, toolInvocationToken: undefined }; +// const token = new vscode.CancellationTokenSource().token; +// await assert.rejects(tool.invoke(options, token), { +// message: 'Unable to access Jupyter kernels for notebook cells', +// }); +// }); + +// test('should return no packages message if no packages are installed', async () => { +// const testFile: IGetActiveFile = { +// filePath: 'abc.py', +// }; +// const options = { input: testFile, toolInvocationToken: undefined }; +// const token = new vscode.CancellationTokenSource().token; + +// // Stub the getPackages function to return an empty array +// const pkg1ID: PackageId = { id: 'package1', managerId: 'pip', environmentId: 'env1' }; +// const package1: Package = { pkgId: pkg1ID, name: 'pkg1', displayName: 'pkg1' }; +// mockApi.getPackages.resolves([package1]); + +// const result = await tool.invoke(options, token); +// assert.strictEqual(result.parts[0].text, 'No packages are installed in the current environment.'); +// }); + +// test('should return installed packages', async () => { +// const options = { input: { filePath: 'test.py' } }; +// const token = new vscode.CancellationTokenSource().token; + +// // Stub the getPackages function to return a list of packages +// mockApi.getPackages.resolves([{ name: 'package1' }, { name: 'package2' }]); +// (getPythonApi as any) = async () => mockApi; + +// const result = await tool.invoke(options, token); +// assert.strictEqual( +// result.parts[0].text, +// 'The packages installed in the current environment are as follows:\npackage1, package2', +// ); +// }); + +// test('should handle cancellation', async () => { +// const options = { input: { filePath: 'test.py' } }; +// const tokenSource = new vscode.CancellationTokenSource(); +// const token = tokenSource.token; + +// // Stub the getPackages function to return a list of packages +// mockApi.getPackages.resolves([{ name: 'package1' }, { name: 'package2' }]); +// (getPythonApi as any) = async () => mockApi; + +// tokenSource.cancel(); +// await assert.rejects(tool.invoke(options, token), { message: 'Operation cancelled' }); +// }); +// }); diff --git a/src/copilotTools.ts b/src/copilotTools.ts new file mode 100644 index 00000000..07c0c542 --- /dev/null +++ b/src/copilotTools.ts @@ -0,0 +1,90 @@ +import * as vscode from 'vscode'; +import { PythonEnvironmentApi } from './api'; +import { getPythonApi } from './features/pythonApi'; + +export interface IGetActiveFile { + filePath?: string; +} + +/** + * A tool to get the list of installed Python packages in the active environment. + */ +export class GetPackagesTool implements vscode.LanguageModelTool { + /** + * Invokes the tool to get the list of installed packages. + * @param options - The invocation options containing the file path. + * @param token - The cancellation token. + * @returns The result containing the list of installed packages or an error message. + */ + async invoke( + options: vscode.LanguageModelToolInvocationOptions, + token: vscode.CancellationToken, + ): Promise { + const parameters: IGetActiveFile = options.input; + + if (parameters.filePath === undefined) { + throw new Error('Invalid input: filePath is required'); + } + const fileUri = vscode.Uri.file(parameters.filePath); + + // Check if the file is a notebook or a notebook cell + if (fileUri.fsPath.endsWith('.ipynb') || fileUri.scheme === 'vscode-notebook-cell') { + throw new Error('Unable to access Jupyter kernels for notebook cells'); + } + + try { + const pythonApi: PythonEnvironmentApi = await getPythonApi(); + const environment = await pythonApi.getEnvironment(fileUri); + if (!environment) { + throw new Error('No environment found'); + } + await pythonApi.refreshPackages(environment); + const installedPackages = await pythonApi.getPackages(environment); + + let resultMessage: string; + if (!installedPackages || installedPackages.length === 0) { + resultMessage = 'No packages are installed in the current environment.'; + } else { + const packageNames = installedPackages.map((pkg) => pkg.name).join(', '); + resultMessage = 'The packages installed in the current environment are as follows:\n' + packageNames; + } + + if (token.isCancellationRequested) { + throw new Error('Operation cancelled'); + } + + const textPart = new vscode.LanguageModelTextPart(resultMessage || ''); + const result: vscode.LanguageModelToolResult = new vscode.LanguageModelToolResult([textPart]); + return result; + } catch (error) { + const errorMessage = `An error occurred while fetching packages: ${error.message}`; + const textPart = new vscode.LanguageModelTextPart(errorMessage); + return new vscode.LanguageModelToolResult([textPart]); + } + } + + /** + * Prepares the invocation of the tool. + * @param _options - The preparation options. + * @param _token - The cancellation token. + * @returns The prepared tool invocation. + */ + async prepareInvocation?( + _options: vscode.LanguageModelToolInvocationPrepareOptions, + _token: vscode.CancellationToken, + ): Promise { + const message = 'Preparing to fetch the list of installed Python packages...'; + console.log(message); + return { + invocationMessage: message, + }; + } +} + +/** + * Registers the chat tools with the given extension context. + * @param context - The extension context. + */ +export function registerChatTools(context: vscode.ExtensionContext): void { + context.subscriptions.push(vscode.lm.registerTool('python_get_python_packages', new GetPackagesTool())); +} diff --git a/src/extension.ts b/src/extension.ts index c23ea9d3..e49c3727 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -54,10 +54,13 @@ import { EventNames } from './common/telemetry/constants'; import { ensureCorrectVersion } from './common/extVersion'; import { ExistingProjects } from './features/creators/existingProjects'; import { AutoFindProjects } from './features/creators/autoFindProjects'; +import { registerChatTools } from './copilotTools'; export async function activate(context: ExtensionContext): Promise { const start = new StopWatch(); + registerChatTools(context); + // Logging should be set up before anything else. const outputChannel: LogOutputChannel = createLogOutputChannel('Python Environments'); context.subscriptions.push(outputChannel, registerLogger(outputChannel)); From 2ede74723e0e72b20841cdb45fd46948c733df3b Mon Sep 17 00:00:00 2001 From: eleanorjboyd Date: Wed, 22 Jan 2025 09:20:11 -0800 Subject: [PATCH 02/11] redesign of how run works --- package-lock.json | 2 +- package.json | 2 +- src/common/lm.apis.ts | 4 + src/copilotTools.test.ts | 92 -------------- src/copilotTools.ts | 48 +++---- src/extension.ts | 16 ++- src/test/copilotTools.unit.test.ts | 184 +++++++++++++++++++++++++++ src/test/mocks/vsc/extHostedTypes.ts | 9 +- 8 files changed, 235 insertions(+), 122 deletions(-) create mode 100644 src/common/lm.apis.ts delete mode 100644 src/copilotTools.test.ts create mode 100644 src/test/copilotTools.unit.test.ts diff --git a/package-lock.json b/package-lock.json index a68e7dcf..4e1cd6e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@types/node": "20.2.5", "@types/sinon": "^17.0.3", "@types/stack-trace": "0.0.29", - "@types/vscode": "^1.93.0", + "@types/vscode": "^1.96.0", "@types/which": "^3.0.4", "@typescript-eslint/eslint-plugin": "^8.16.0", "@typescript-eslint/parser": "^8.16.0", diff --git a/package.json b/package.json index e3366969..f1ac64c2 100644 --- a/package.json +++ b/package.json @@ -467,7 +467,7 @@ "@types/node": "20.2.5", "@types/sinon": "^17.0.3", "@types/stack-trace": "0.0.29", - "@types/vscode": "^1.93.0", + "@types/vscode": "^1.96.0", "@types/which": "^3.0.4", "@typescript-eslint/eslint-plugin": "^8.16.0", "@typescript-eslint/parser": "^8.16.0", diff --git a/src/common/lm.apis.ts b/src/common/lm.apis.ts new file mode 100644 index 00000000..61de4fa3 --- /dev/null +++ b/src/common/lm.apis.ts @@ -0,0 +1,4 @@ +import * as vscode from 'vscode'; +export function registerTools(name: string, tool: vscode.LanguageModelTool): vscode.Disposable { + return vscode.lm.registerTool(name, tool); +} diff --git a/src/copilotTools.test.ts b/src/copilotTools.test.ts deleted file mode 100644 index be73333c..00000000 --- a/src/copilotTools.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -// import * as assert from 'assert'; -// import * as vscode from 'vscode'; -// import { GetPackagesTool } from './copilotTools'; -// import { Package, PackageId, PackageInfo, PythonEnvironmentApi } from './api'; -// import { IGetActiveFile } from './copilotTools'; -// import * as sinon from 'sinon'; - -// suite('GetPackagesTool Tests', () => { -// let tool: GetPackagesTool; -// let mockApi: sinon.SinonStubbedInstance; - -// setup(() => { -// tool = new GetPackagesTool(); -// // Create a stub instance of the PythonEnvironmentApi interface -// mockApi = sinon.createStubInstance({} as any); -// }); - -// test('should throw error if filePath is undefined', async () => { -// const testFile: IGetActiveFile = { -// filePath: '', -// }; -// const options = { input: testFile, toolInvocationToken: undefined }; -// const token = new vscode.CancellationTokenSource().token; -// await assert.rejects(tool.invoke(options, token), { message: 'Invalid input: filePath is required' }); -// }); - -// test('should throw error for notebook files', async () => { -// const testFile: IGetActiveFile = { -// filePath: 'test.ipynb', -// }; -// const options = { input: testFile, toolInvocationToken: undefined }; -// const token = new vscode.CancellationTokenSource().token; -// await assert.rejects(tool.invoke(options, token), { -// message: 'Unable to access Jupyter kernels for notebook cells', -// }); -// }); - -// test('should throw error for notebook cells', async () => { -// const testFile: IGetActiveFile = { -// filePath: 'test.ipynb#cell', -// }; -// const options = { input: testFile, toolInvocationToken: undefined }; -// const token = new vscode.CancellationTokenSource().token; -// await assert.rejects(tool.invoke(options, token), { -// message: 'Unable to access Jupyter kernels for notebook cells', -// }); -// }); - -// test('should return no packages message if no packages are installed', async () => { -// const testFile: IGetActiveFile = { -// filePath: 'abc.py', -// }; -// const options = { input: testFile, toolInvocationToken: undefined }; -// const token = new vscode.CancellationTokenSource().token; - -// // Stub the getPackages function to return an empty array -// const pkg1ID: PackageId = { id: 'package1', managerId: 'pip', environmentId: 'env1' }; -// const package1: Package = { pkgId: pkg1ID, name: 'pkg1', displayName: 'pkg1' }; -// mockApi.getPackages.resolves([package1]); - -// const result = await tool.invoke(options, token); -// assert.strictEqual(result.parts[0].text, 'No packages are installed in the current environment.'); -// }); - -// test('should return installed packages', async () => { -// const options = { input: { filePath: 'test.py' } }; -// const token = new vscode.CancellationTokenSource().token; - -// // Stub the getPackages function to return a list of packages -// mockApi.getPackages.resolves([{ name: 'package1' }, { name: 'package2' }]); -// (getPythonApi as any) = async () => mockApi; - -// const result = await tool.invoke(options, token); -// assert.strictEqual( -// result.parts[0].text, -// 'The packages installed in the current environment are as follows:\npackage1, package2', -// ); -// }); - -// test('should handle cancellation', async () => { -// const options = { input: { filePath: 'test.py' } }; -// const tokenSource = new vscode.CancellationTokenSource(); -// const token = tokenSource.token; - -// // Stub the getPackages function to return a list of packages -// mockApi.getPackages.resolves([{ name: 'package1' }, { name: 'package2' }]); -// (getPythonApi as any) = async () => mockApi; - -// tokenSource.cancel(); -// await assert.rejects(tool.invoke(options, token), { message: 'Operation cancelled' }); -// }); -// }); diff --git a/src/copilotTools.ts b/src/copilotTools.ts index 07c0c542..cc8b9fbf 100644 --- a/src/copilotTools.ts +++ b/src/copilotTools.ts @@ -1,6 +1,5 @@ import * as vscode from 'vscode'; -import { PythonEnvironmentApi } from './api'; -import { getPythonApi } from './features/pythonApi'; +import { GetEnvironmentScope, Package, PythonEnvironment } from './api'; export interface IGetActiveFile { filePath?: string; @@ -10,6 +9,19 @@ export interface IGetActiveFile { * A tool to get the list of installed Python packages in the active environment. */ export class GetPackagesTool implements vscode.LanguageModelTool { + private apiGetEnvironment: (scope: GetEnvironmentScope) => Promise; + private apiGetPackages: (environment: PythonEnvironment) => Promise; + + private apiRefreshPackages: (environment: PythonEnvironment) => Promise; + constructor( + apiGetEnvironmentCon: (scope: GetEnvironmentScope) => Promise, + apiGetPackagesCon: (environment: PythonEnvironment) => Promise, + apiRefreshPackagesCon: (environment: PythonEnvironment) => Promise, + ) { + this.apiGetEnvironment = apiGetEnvironmentCon; + this.apiGetPackages = apiGetPackagesCon; + this.apiRefreshPackages = apiRefreshPackagesCon; + } /** * Invokes the tool to get the list of installed packages. * @param options - The invocation options containing the file path. @@ -22,24 +34,22 @@ export class GetPackagesTool implements vscode.LanguageModelTool ): Promise { const parameters: IGetActiveFile = options.input; - if (parameters.filePath === undefined) { + if (parameters.filePath === undefined || parameters.filePath === '') { throw new Error('Invalid input: filePath is required'); } const fileUri = vscode.Uri.file(parameters.filePath); - // Check if the file is a notebook or a notebook cell - if (fileUri.fsPath.endsWith('.ipynb') || fileUri.scheme === 'vscode-notebook-cell') { - throw new Error('Unable to access Jupyter kernels for notebook cells'); - } - try { - const pythonApi: PythonEnvironmentApi = await getPythonApi(); - const environment = await pythonApi.getEnvironment(fileUri); + const environment = await this.apiGetEnvironment(fileUri); if (!environment) { + // Check if the file is a notebook or a notebook cell to throw specific error messages. + if (fileUri.fsPath.endsWith('.ipynb') || fileUri.fsPath.includes('.ipynb#')) { + throw new Error('Unable to access Jupyter kernels for notebook cells'); + } throw new Error('No environment found'); } - await pythonApi.refreshPackages(environment); - const installedPackages = await pythonApi.getPackages(environment); + await this.apiRefreshPackages(environment); + const installedPackages = await this.apiGetPackages(environment); let resultMessage: string; if (!installedPackages || installedPackages.length === 0) { @@ -54,12 +64,12 @@ export class GetPackagesTool implements vscode.LanguageModelTool } const textPart = new vscode.LanguageModelTextPart(resultMessage || ''); - const result: vscode.LanguageModelToolResult = new vscode.LanguageModelToolResult([textPart]); + const result: vscode.LanguageModelToolResult = { content: [textPart] }; return result; } catch (error) { - const errorMessage = `An error occurred while fetching packages: ${error.message}`; + const errorMessage: string = `An error occurred while fetching packages: ${error}`; const textPart = new vscode.LanguageModelTextPart(errorMessage); - return new vscode.LanguageModelToolResult([textPart]); + return { content: [textPart] } as vscode.LanguageModelToolResult; } } @@ -80,11 +90,3 @@ export class GetPackagesTool implements vscode.LanguageModelTool }; } } - -/** - * Registers the chat tools with the given extension context. - * @param context - The extension context. - */ -export function registerChatTools(context: vscode.ExtensionContext): void { - context.subscriptions.push(vscode.lm.registerTool('python_get_python_packages', new GetPackagesTool())); -} diff --git a/src/extension.ts b/src/extension.ts index e49c3727..3ea3fa93 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -54,13 +54,12 @@ import { EventNames } from './common/telemetry/constants'; import { ensureCorrectVersion } from './common/extVersion'; import { ExistingProjects } from './features/creators/existingProjects'; import { AutoFindProjects } from './features/creators/autoFindProjects'; -import { registerChatTools } from './copilotTools'; +import { GetPackagesTool } from './copilotTools'; +import { registerTools } from './common/lm.apis'; export async function activate(context: ExtensionContext): Promise { const start = new StopWatch(); - registerChatTools(context); - // Logging should be set up before anything else. const outputChannel: LogOutputChannel = createLogOutputChannel('Python Environments'); context.subscriptions.push(outputChannel, registerLogger(outputChannel)); @@ -235,6 +234,17 @@ export async function activate(context: ExtensionContext): Promise api.getEnvironment(scope), + (scope) => api.getPackages(scope), + (scope) => api.refreshPackages(scope), + ), + ); + return api; } diff --git a/src/test/copilotTools.unit.test.ts b/src/test/copilotTools.unit.test.ts new file mode 100644 index 00000000..cd7496ae --- /dev/null +++ b/src/test/copilotTools.unit.test.ts @@ -0,0 +1,184 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { GetPackagesTool } from '../copilotTools'; +//import { PythonEnvironment, Package } from '../api'; +import { IGetActiveFile } from '../copilotTools'; +import * as sinon from 'sinon'; +import * as typeMoq from 'typemoq'; +import { GetEnvironmentScope, Package, PythonEnvironment } from '../api'; + +suite('GetPackagesTool Tests', () => { + let tool: GetPackagesTool; + let mockGetEnvironment: typeMoq.IMock<(scope: GetEnvironmentScope) => Promise>; + let mockGetPackages: typeMoq.IMock<(environment: PythonEnvironment) => Promise>; + let mockRefreshPackages: typeMoq.IMock<(environment: PythonEnvironment) => Promise>; + let mockEnvironment: typeMoq.IMock; + + setup(() => { + // Create mock functions + mockGetEnvironment = + typeMoq.Mock.ofType<(scope: GetEnvironmentScope) => Promise>(); + mockGetPackages = typeMoq.Mock.ofType<(environment: PythonEnvironment) => Promise>(); + mockRefreshPackages = typeMoq.Mock.ofType<(environment: PythonEnvironment) => Promise>(); + mockEnvironment = typeMoq.Mock.ofType(); + + // Create an instance of GetPackagesTool with the mock functions + tool = new GetPackagesTool(mockGetEnvironment.object, mockGetPackages.object, mockRefreshPackages.object); + }); + + teardown(() => { + sinon.restore(); + }); + + test('should throw error if filePath is undefined', async () => { + mockGetEnvironment.setup((x) => x(typeMoq.It.isAny())).returns(async () => undefined); + + const testFile: IGetActiveFile = { + filePath: '', + }; + const options = { input: testFile, toolInvocationToken: undefined }; + const token = new vscode.CancellationTokenSource().token; + await assert.rejects(tool.invoke(options, token), { message: 'Invalid input: filePath is required' }); + }); + + test('should throw error for notebook files', async () => { + mockGetEnvironment.setup((x) => x(typeMoq.It.isAny())).returns(async () => undefined); + + const testFile: IGetActiveFile = { + filePath: 'test.ipynb', + }; + const options = { input: testFile, toolInvocationToken: undefined }; + const token = new vscode.CancellationTokenSource().token; + const result = await tool.invoke(options, token); + const content = result.content as vscode.LanguageModelTextPart[]; + const firstPart = content[0] as vscode.MarkdownString; + + assert.strictEqual( + firstPart.value, + 'An error occurred while fetching packages: Error: Unable to access Jupyter kernels for notebook cells', + ); + }); + + test('should throw error for notebook cells', async () => { + mockGetEnvironment.setup((x) => x(typeMoq.It.isAny())).returns(async () => undefined); + + const testFile: IGetActiveFile = { + filePath: 'test.ipynb#123', + }; + const options = { input: testFile, toolInvocationToken: undefined }; + const token = new vscode.CancellationTokenSource().token; + const result = await tool.invoke(options, token); + const content = result.content as vscode.LanguageModelTextPart[]; + const firstPart = content[0] as vscode.MarkdownString; + + assert.strictEqual( + firstPart.value, + 'An error occurred while fetching packages: Error: Unable to access Jupyter kernels for notebook cells', + ); + }); + + // test('should return no packages message if no packages are installed', async () => { + // mockGetEnvironment + // .setup((x) => x(typeMoq.It.isAny())) + // .returns(async () => { + // console.log('hi'); + // return Promise.resolve(mockEnvironment.object); + // }); + + // const testFile: IGetActiveFile = { + // filePath: 'test.py', + // }; + // const options = { input: testFile, toolInvocationToken: undefined }; + // const token = new vscode.CancellationTokenSource().token; + // const result = await tool.invoke(options, token); + // const content = result.content as vscode.LanguageModelTextPart[]; + // const firstPart = content[0] as vscode.MarkdownString; + + // assert.strictEqual(firstPart.value, 'No packages are installed in the current environment.'); + // }); + + // test('should return installed packages', async () => { + // const testFile: IGetActiveFile = { + // filePath: 'abc.py', + // }; + // const options = { input: testFile, toolInvocationToken: undefined }; + // const token = new vscode.CancellationTokenSource().token; + + // // Mock the getEnvironment function to return a valid environment + // const mockEnvironment: PythonEnvironment = { + // name: 'env', + // displayName: 'env', + // displayPath: 'path/to/env', + // version: '3.9.0', + // environmentPath: vscode.Uri.file('path/to/env'), + // sysPrefix: 'path/to/env', + // execInfo: { run: { executable: 'python' } }, + // envId: { id: 'env1', managerId: 'manager1' }, + // }; + // mockGetEnvironment.resolves(mockEnvironment); + + // // Mock the getPackages function to return a list of packages + // const mockPackages: Package[] = [ + // { + // pkgId: { id: 'pkg1', managerId: 'pip', environmentId: 'env1' }, + // name: 'package1', + // displayName: 'package1', + // }, + // { + // pkgId: { id: 'pkg2', managerId: 'pip', environmentId: 'env1' }, + // name: 'package2', + // displayName: 'package2', + // }, + // ]; + // mockGetPackages.resolves(mockPackages); + + // const result = await tool.invoke(options, token); + // assert.strictEqual( + // result.parts[0].text, + // 'The packages installed in the current environment are as follows:\npackage1, package2', + // ); + // }); + + // test('should handle cancellation', async () => { + // const tokenSource = new vscode.CancellationTokenSource(); + // const token = tokenSource.token; + + // const testFile: IGetActiveFile = { + // filePath: 'abc.py', + // }; + // const options = { input: testFile, toolInvocationToken: undefined }; + + // // Mock the getEnvironment function to return a valid environment + // const mockEnvironment: PythonEnvironment = { + // name: 'env', + // displayName: 'env', + // displayPath: 'path/to/env', + // version: '3.9.0', + // environmentPath: vscode.Uri.file('path/to/env'), + // sysPrefix: 'path/to/env', + // execInfo: { run: { executable: 'python' } }, + // envId: { id: 'env1', managerId: 'manager1' }, + // }; + // mockGetEnvironment.resolves(mockEnvironment); + + // // Mock the getPackages function to return a list of packages + // const mockPackages: Package[] = [ + // { + // pkgId: { id: 'pkg1', managerId: 'pip', environmentId: 'env1' }, + // name: 'package1', + // displayName: 'package1', + // }, + // { + // pkgId: { id: 'pkg2', managerId: 'pip', environmentId: 'env1' }, + // name: 'package2', + // displayName: 'package2', + // }, + // ]; + // mockGetPackages.resolves(mockPackages); + + // tool.invoke(options, token); + + // tokenSource.cancel(); + // await assert.rejects(tool.invoke(options, token), { message: 'Operation cancelled' }); + // }); +}); diff --git a/src/test/mocks/vsc/extHostedTypes.ts b/src/test/mocks/vsc/extHostedTypes.ts index 1da86593..d80b45d0 100644 --- a/src/test/mocks/vsc/extHostedTypes.ts +++ b/src/test/mocks/vsc/extHostedTypes.ts @@ -1986,17 +1986,22 @@ export enum TreeItemCollapsibleState { export class TreeItem { label?: string; + id?: string; + description?: string | boolean; resourceUri?: vscUri.URI; - iconPath?: string | vscUri.URI | { light: string | vscUri.URI; dark: string | vscUri.URI }; - + iconPath?: string | vscode.IconPath; command?: vscode.Command; contextValue?: string; tooltip?: string; + checkboxState?: vscode.TreeItemCheckboxState; + + accessibilityInformation?: vscode.AccessibilityInformation; + constructor(label: string, collapsibleState?: vscode.TreeItemCollapsibleState); constructor(resourceUri: vscUri.URI, collapsibleState?: vscode.TreeItemCollapsibleState); From ce019aeff67330b73a0cb7595bc1a4f856f9a71a Mon Sep 17 00:00:00 2001 From: eleanorjboyd Date: Wed, 22 Jan 2025 10:11:43 -0800 Subject: [PATCH 03/11] add mocked copilot tools types --- src/copilotTools.ts | 35 ++++++++++++------- src/test/copilotTools.unit.test.ts | 36 +++++++++---------- src/test/mocks/vsc/copilotTools.ts | 56 ++++++++++++++++++++++++++++++ src/test/mocks/vsc/index.ts | 1 + src/test/unittests.ts | 2 ++ 5 files changed, 99 insertions(+), 31 deletions(-) create mode 100644 src/test/mocks/vsc/copilotTools.ts diff --git a/src/copilotTools.ts b/src/copilotTools.ts index cc8b9fbf..d8fb1bee 100644 --- a/src/copilotTools.ts +++ b/src/copilotTools.ts @@ -1,4 +1,13 @@ -import * as vscode from 'vscode'; +import { + CancellationToken, + LanguageModelTextPart, + LanguageModelTool, + LanguageModelToolInvocationOptions, + LanguageModelToolInvocationPrepareOptions, + LanguageModelToolResult, + PreparedToolInvocation, + Uri, +} from 'vscode'; import { GetEnvironmentScope, Package, PythonEnvironment } from './api'; export interface IGetActiveFile { @@ -8,7 +17,7 @@ export interface IGetActiveFile { /** * A tool to get the list of installed Python packages in the active environment. */ -export class GetPackagesTool implements vscode.LanguageModelTool { +export class GetPackagesTool implements LanguageModelTool { private apiGetEnvironment: (scope: GetEnvironmentScope) => Promise; private apiGetPackages: (environment: PythonEnvironment) => Promise; @@ -29,15 +38,15 @@ export class GetPackagesTool implements vscode.LanguageModelTool * @returns The result containing the list of installed packages or an error message. */ async invoke( - options: vscode.LanguageModelToolInvocationOptions, - token: vscode.CancellationToken, - ): Promise { + options: LanguageModelToolInvocationOptions, + token: CancellationToken, + ): Promise { const parameters: IGetActiveFile = options.input; if (parameters.filePath === undefined || parameters.filePath === '') { throw new Error('Invalid input: filePath is required'); } - const fileUri = vscode.Uri.file(parameters.filePath); + const fileUri = Uri.file(parameters.filePath); try { const environment = await this.apiGetEnvironment(fileUri); @@ -63,13 +72,13 @@ export class GetPackagesTool implements vscode.LanguageModelTool throw new Error('Operation cancelled'); } - const textPart = new vscode.LanguageModelTextPart(resultMessage || ''); - const result: vscode.LanguageModelToolResult = { content: [textPart] }; + const textPart = new LanguageModelTextPart(resultMessage || ''); + const result: LanguageModelToolResult = { content: [textPart] }; return result; } catch (error) { const errorMessage: string = `An error occurred while fetching packages: ${error}`; - const textPart = new vscode.LanguageModelTextPart(errorMessage); - return { content: [textPart] } as vscode.LanguageModelToolResult; + const textPart = new LanguageModelTextPart(errorMessage); + return { content: [textPart] } as LanguageModelToolResult; } } @@ -80,9 +89,9 @@ export class GetPackagesTool implements vscode.LanguageModelTool * @returns The prepared tool invocation. */ async prepareInvocation?( - _options: vscode.LanguageModelToolInvocationPrepareOptions, - _token: vscode.CancellationToken, - ): Promise { + _options: LanguageModelToolInvocationPrepareOptions, + _token: CancellationToken, + ): Promise { const message = 'Preparing to fetch the list of installed Python packages...'; console.log(message); return { diff --git a/src/test/copilotTools.unit.test.ts b/src/test/copilotTools.unit.test.ts index cd7496ae..02df669f 100644 --- a/src/test/copilotTools.unit.test.ts +++ b/src/test/copilotTools.unit.test.ts @@ -51,7 +51,7 @@ suite('GetPackagesTool Tests', () => { const token = new vscode.CancellationTokenSource().token; const result = await tool.invoke(options, token); const content = result.content as vscode.LanguageModelTextPart[]; - const firstPart = content[0] as vscode.MarkdownString; + const firstPart = content[0] as vscode.LanguageModelTextPart; assert.strictEqual( firstPart.value, @@ -77,25 +77,25 @@ suite('GetPackagesTool Tests', () => { ); }); - // test('should return no packages message if no packages are installed', async () => { - // mockGetEnvironment - // .setup((x) => x(typeMoq.It.isAny())) - // .returns(async () => { - // console.log('hi'); - // return Promise.resolve(mockEnvironment.object); - // }); + test('should return no packages message if no packages are installed', async () => { + mockGetEnvironment + .setup((x) => x(typeMoq.It.isAny())) + .returns(async () => { + console.log('hi'); + return Promise.resolve(mockEnvironment.object); + }); - // const testFile: IGetActiveFile = { - // filePath: 'test.py', - // }; - // const options = { input: testFile, toolInvocationToken: undefined }; - // const token = new vscode.CancellationTokenSource().token; - // const result = await tool.invoke(options, token); - // const content = result.content as vscode.LanguageModelTextPart[]; - // const firstPart = content[0] as vscode.MarkdownString; + const testFile: IGetActiveFile = { + filePath: 'test.py', + }; + const options = { input: testFile, toolInvocationToken: undefined }; + const token = new vscode.CancellationTokenSource().token; + const result = await tool.invoke(options, token); + const content = result.content as vscode.LanguageModelTextPart[]; + const firstPart = content[0] as vscode.MarkdownString; - // assert.strictEqual(firstPart.value, 'No packages are installed in the current environment.'); - // }); + assert.strictEqual(firstPart.value, 'No packages are installed in the current environment.'); + }); // test('should return installed packages', async () => { // const testFile: IGetActiveFile = { diff --git a/src/test/mocks/vsc/copilotTools.ts b/src/test/mocks/vsc/copilotTools.ts new file mode 100644 index 00000000..7ec49d43 --- /dev/null +++ b/src/test/mocks/vsc/copilotTools.ts @@ -0,0 +1,56 @@ +/** + * A language model response part containing a piece of text, returned from a {@link LanguageModelChatResponse}. + */ +export class LanguageModelTextPart { + /** + * The text content of the part. + */ + value: string; + + /** + * Construct a text part with the given content. + * @param value The text content of the part. + */ + constructor(value: string) { + this.value = value; + } +} + +/** + * A result returned from a tool invocation. If using `@vscode/prompt-tsx`, this result may be rendered using a `ToolResult`. + */ +export class LanguageModelToolResult { + /** + * A list of tool result content parts. Includes `unknown` becauses this list may be extended with new content types in + * the future. + * @see {@link lm.invokeTool}. + */ + content: Array; + + /** + * Create a LanguageModelToolResult + * @param content A list of tool result content parts + */ + constructor(content: Array) { + this.content = content; + } +} + +/** + * A language model response part containing a PromptElementJSON from `@vscode/prompt-tsx`. + * @see {@link LanguageModelToolResult} + */ +export class LanguageModelPromptTsxPart { + /** + * The value of the part. + */ + value: unknown; + + /** + * Construct a prompt-tsx part with the given content. + * @param value The value of the part, the result of `renderPromptElementJSON` from `@vscode/prompt-tsx`. + */ + constructor(value: unknown) { + this.value = value; + } +} diff --git a/src/test/mocks/vsc/index.ts b/src/test/mocks/vsc/index.ts index 60493395..39e96b09 100644 --- a/src/test/mocks/vsc/index.ts +++ b/src/test/mocks/vsc/index.ts @@ -8,6 +8,7 @@ import * as vscode from 'vscode'; // export * from './selection'; export * as vscMockExtHostedTypes from './extHostedTypes'; export * as vscUri from './uri'; +export * as vscMockCopilotTools from './copilotTools'; const escapeCodiconsRegex = /(\\)?\$\([a-z0-9\-]+?(?:~[a-z0-9\-]*?)?\)/gi; export function escapeCodicons(text: string): string { diff --git a/src/test/unittests.ts b/src/test/unittests.ts index caf5fa0a..4d0ad933 100644 --- a/src/test/unittests.ts +++ b/src/test/unittests.ts @@ -130,4 +130,6 @@ mockedVSCode.LogLevel = vscodeMocks.LogLevel; (mockedVSCode as any).CancellationError = vscodeMocks.vscMockExtHostedTypes.CancellationError; (mockedVSCode as any).LSPCancellationError = vscodeMocks.vscMockExtHostedTypes.LSPCancellationError; mockedVSCode.TestRunProfileKind = vscodeMocks.TestRunProfileKind; +mockedVSCode.LanguageModelTextPart = vscodeMocks.vscMockCopilotTools.LanguageModelTextPart; + initialize(); From fe141ec5f91c1e306e9c2c279770bb66e0d25155 Mon Sep 17 00:00:00 2001 From: eleanorjboyd Date: Wed, 22 Jan 2025 13:33:37 -0800 Subject: [PATCH 04/11] fix tests to be passing --- src/copilotTools.ts | 41 +++--- src/extension.ts | 11 +- src/test/copilotTools.unit.test.ts | 201 ++++++++++++++--------------- 3 files changed, 123 insertions(+), 130 deletions(-) diff --git a/src/copilotTools.ts b/src/copilotTools.ts index d8fb1bee..c7e29d81 100644 --- a/src/copilotTools.ts +++ b/src/copilotTools.ts @@ -8,7 +8,8 @@ import { PreparedToolInvocation, Uri, } from 'vscode'; -import { GetEnvironmentScope, Package, PythonEnvironment } from './api'; +import { PythonPackageGetterApi, PythonProjectEnvironmentApi } from './api'; +import { createDeferred } from './common/utils/deferred'; export interface IGetActiveFile { filePath?: string; @@ -18,19 +19,7 @@ export interface IGetActiveFile { * A tool to get the list of installed Python packages in the active environment. */ export class GetPackagesTool implements LanguageModelTool { - private apiGetEnvironment: (scope: GetEnvironmentScope) => Promise; - private apiGetPackages: (environment: PythonEnvironment) => Promise; - - private apiRefreshPackages: (environment: PythonEnvironment) => Promise; - constructor( - apiGetEnvironmentCon: (scope: GetEnvironmentScope) => Promise, - apiGetPackagesCon: (environment: PythonEnvironment) => Promise, - apiRefreshPackagesCon: (environment: PythonEnvironment) => Promise, - ) { - this.apiGetEnvironment = apiGetEnvironmentCon; - this.apiGetPackages = apiGetPackagesCon; - this.apiRefreshPackages = apiRefreshPackagesCon; - } + constructor(private readonly api: PythonProjectEnvironmentApi & PythonPackageGetterApi) {} /** * Invokes the tool to get the list of installed packages. * @param options - The invocation options containing the file path. @@ -41,6 +30,12 @@ export class GetPackagesTool implements LanguageModelTool { options: LanguageModelToolInvocationOptions, token: CancellationToken, ): Promise { + const deferredReturn = createDeferred(); + token.onCancellationRequested(() => { + const errorMessage: string = `Operation cancelled by the user.`; + deferredReturn.resolve({ content: [new LanguageModelTextPart(errorMessage)] } as LanguageModelToolResult); + }); + const parameters: IGetActiveFile = options.input; if (parameters.filePath === undefined || parameters.filePath === '') { @@ -49,7 +44,7 @@ export class GetPackagesTool implements LanguageModelTool { const fileUri = Uri.file(parameters.filePath); try { - const environment = await this.apiGetEnvironment(fileUri); + const environment = await this.api.getEnvironment(fileUri); if (!environment) { // Check if the file is a notebook or a notebook cell to throw specific error messages. if (fileUri.fsPath.endsWith('.ipynb') || fileUri.fsPath.includes('.ipynb#')) { @@ -57,8 +52,8 @@ export class GetPackagesTool implements LanguageModelTool { } throw new Error('No environment found'); } - await this.apiRefreshPackages(environment); - const installedPackages = await this.apiGetPackages(environment); + await this.api.refreshPackages(environment); + const installedPackages = await this.api.getPackages(environment); let resultMessage: string; if (!installedPackages || installedPackages.length === 0) { @@ -68,18 +63,13 @@ export class GetPackagesTool implements LanguageModelTool { resultMessage = 'The packages installed in the current environment are as follows:\n' + packageNames; } - if (token.isCancellationRequested) { - throw new Error('Operation cancelled'); - } - const textPart = new LanguageModelTextPart(resultMessage || ''); - const result: LanguageModelToolResult = { content: [textPart] }; - return result; + deferredReturn.resolve({ content: [textPart] }); } catch (error) { const errorMessage: string = `An error occurred while fetching packages: ${error}`; - const textPart = new LanguageModelTextPart(errorMessage); - return { content: [textPart] } as LanguageModelToolResult; + deferredReturn.resolve({ content: [new LanguageModelTextPart(errorMessage)] } as LanguageModelToolResult); } + return deferredReturn.promise; } /** @@ -93,7 +83,6 @@ export class GetPackagesTool implements LanguageModelTool { _token: CancellationToken, ): Promise { const message = 'Preparing to fetch the list of installed Python packages...'; - console.log(message); return { invocationMessage: message, }; diff --git a/src/extension.ts b/src/extension.ts index 3ea3fa93..2d2d1c6e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -104,6 +104,13 @@ export async function activate(context: ExtensionContext): Promise outputChannel.show()), commands.registerCommand('python-envs.refreshManager', async (item) => { await refreshManagerCommand(item); @@ -239,9 +246,7 @@ export async function activate(context: ExtensionContext): Promise api.getEnvironment(scope), - (scope) => api.getPackages(scope), - (scope) => api.refreshPackages(scope), + api ), ); diff --git a/src/test/copilotTools.unit.test.ts b/src/test/copilotTools.unit.test.ts index 02df669f..ff03331a 100644 --- a/src/test/copilotTools.unit.test.ts +++ b/src/test/copilotTools.unit.test.ts @@ -5,25 +5,24 @@ import { GetPackagesTool } from '../copilotTools'; import { IGetActiveFile } from '../copilotTools'; import * as sinon from 'sinon'; import * as typeMoq from 'typemoq'; -import { GetEnvironmentScope, Package, PythonEnvironment } from '../api'; +import { Package, PythonEnvironment, PythonPackageGetterApi, PythonProjectEnvironmentApi } from '../api'; +import { createDeferred } from '../common/utils/deferred'; suite('GetPackagesTool Tests', () => { let tool: GetPackagesTool; - let mockGetEnvironment: typeMoq.IMock<(scope: GetEnvironmentScope) => Promise>; - let mockGetPackages: typeMoq.IMock<(environment: PythonEnvironment) => Promise>; - let mockRefreshPackages: typeMoq.IMock<(environment: PythonEnvironment) => Promise>; + let mockApi: typeMoq.IMock; let mockEnvironment: typeMoq.IMock; setup(() => { // Create mock functions - mockGetEnvironment = - typeMoq.Mock.ofType<(scope: GetEnvironmentScope) => Promise>(); - mockGetPackages = typeMoq.Mock.ofType<(environment: PythonEnvironment) => Promise>(); - mockRefreshPackages = typeMoq.Mock.ofType<(environment: PythonEnvironment) => Promise>(); + mockApi = typeMoq.Mock.ofType(); mockEnvironment = typeMoq.Mock.ofType(); + // refresh will always return a resolved promise + mockApi.setup((x) => x.refreshPackages(typeMoq.It.isAny())).returns(() => Promise.resolve()); + // Create an instance of GetPackagesTool with the mock functions - tool = new GetPackagesTool(mockGetEnvironment.object, mockGetPackages.object, mockRefreshPackages.object); + tool = new GetPackagesTool(mockApi.object); }); teardown(() => { @@ -31,7 +30,8 @@ suite('GetPackagesTool Tests', () => { }); test('should throw error if filePath is undefined', async () => { - mockGetEnvironment.setup((x) => x(typeMoq.It.isAny())).returns(async () => undefined); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockEnvironment.setup((x: any) => x.then).returns(() => undefined); const testFile: IGetActiveFile = { filePath: '', @@ -42,7 +42,8 @@ suite('GetPackagesTool Tests', () => { }); test('should throw error for notebook files', async () => { - mockGetEnvironment.setup((x) => x(typeMoq.It.isAny())).returns(async () => undefined); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockEnvironment.setup((x: any) => x.then).returns(() => undefined); const testFile: IGetActiveFile = { filePath: 'test.ipynb', @@ -60,7 +61,8 @@ suite('GetPackagesTool Tests', () => { }); test('should throw error for notebook cells', async () => { - mockGetEnvironment.setup((x) => x(typeMoq.It.isAny())).returns(async () => undefined); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockEnvironment.setup((x: any) => x.then).returns(() => undefined); const testFile: IGetActiveFile = { filePath: 'test.ipynb#123', @@ -78,107 +80,104 @@ suite('GetPackagesTool Tests', () => { }); test('should return no packages message if no packages are installed', async () => { - mockGetEnvironment - .setup((x) => x(typeMoq.It.isAny())) - .returns(async () => { - console.log('hi'); + const testFile: IGetActiveFile = { + filePath: 'test.py', + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockEnvironment.setup((x: any) => x.then).returns(() => undefined); + + mockApi + .setup((x) => x.getEnvironment(typeMoq.It.isAny())) + .returns(() => { return Promise.resolve(mockEnvironment.object); }); + mockApi.setup((x) => x.getPackages(typeMoq.It.isAny())).returns(() => Promise.resolve([])); + + const options = { input: testFile, toolInvocationToken: undefined }; + const token = new vscode.CancellationTokenSource().token; + const result = await tool.invoke(options, token); + const content = result.content as vscode.LanguageModelTextPart[]; + const firstPart = content[0] as vscode.MarkdownString; + + assert.strictEqual(firstPart.value, 'No packages are installed in the current environment.'); + }); + + test('should return installed packages', async () => { const testFile: IGetActiveFile = { filePath: 'test.py', }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockEnvironment.setup((x: any) => x.then).returns(() => undefined); + + mockApi + .setup((x) => x.getEnvironment(typeMoq.It.isAny())) + .returns(() => { + return Promise.resolve(mockEnvironment.object); + }); + + const mockPackages: Package[] = [ + { + pkgId: { id: 'pkg1', managerId: 'pip', environmentId: 'env1' }, + name: 'package1', + displayName: 'package1', + }, + { + pkgId: { id: 'pkg2', managerId: 'pip', environmentId: 'env1' }, + name: 'package2', + displayName: 'package2', + }, + ]; + + mockApi.setup((x) => x.refreshPackages(typeMoq.It.isAny())).returns(() => Promise.resolve()); + mockApi.setup((x) => x.getPackages(typeMoq.It.isAny())).returns(() => Promise.resolve(mockPackages)); + const options = { input: testFile, toolInvocationToken: undefined }; const token = new vscode.CancellationTokenSource().token; const result = await tool.invoke(options, token); const content = result.content as vscode.LanguageModelTextPart[]; const firstPart = content[0] as vscode.MarkdownString; - assert.strictEqual(firstPart.value, 'No packages are installed in the current environment.'); + assert.ok( + firstPart.value.includes('The packages installed in the current environment are as follows:') && + firstPart.value.includes('package1') && + firstPart.value.includes('package2'), + ); }); - // test('should return installed packages', async () => { - // const testFile: IGetActiveFile = { - // filePath: 'abc.py', - // }; - // const options = { input: testFile, toolInvocationToken: undefined }; - // const token = new vscode.CancellationTokenSource().token; - - // // Mock the getEnvironment function to return a valid environment - // const mockEnvironment: PythonEnvironment = { - // name: 'env', - // displayName: 'env', - // displayPath: 'path/to/env', - // version: '3.9.0', - // environmentPath: vscode.Uri.file('path/to/env'), - // sysPrefix: 'path/to/env', - // execInfo: { run: { executable: 'python' } }, - // envId: { id: 'env1', managerId: 'manager1' }, - // }; - // mockGetEnvironment.resolves(mockEnvironment); - - // // Mock the getPackages function to return a list of packages - // const mockPackages: Package[] = [ - // { - // pkgId: { id: 'pkg1', managerId: 'pip', environmentId: 'env1' }, - // name: 'package1', - // displayName: 'package1', - // }, - // { - // pkgId: { id: 'pkg2', managerId: 'pip', environmentId: 'env1' }, - // name: 'package2', - // displayName: 'package2', - // }, - // ]; - // mockGetPackages.resolves(mockPackages); - - // const result = await tool.invoke(options, token); - // assert.strictEqual( - // result.parts[0].text, - // 'The packages installed in the current environment are as follows:\npackage1, package2', - // ); - // }); - - // test('should handle cancellation', async () => { - // const tokenSource = new vscode.CancellationTokenSource(); - // const token = tokenSource.token; - - // const testFile: IGetActiveFile = { - // filePath: 'abc.py', - // }; - // const options = { input: testFile, toolInvocationToken: undefined }; - - // // Mock the getEnvironment function to return a valid environment - // const mockEnvironment: PythonEnvironment = { - // name: 'env', - // displayName: 'env', - // displayPath: 'path/to/env', - // version: '3.9.0', - // environmentPath: vscode.Uri.file('path/to/env'), - // sysPrefix: 'path/to/env', - // execInfo: { run: { executable: 'python' } }, - // envId: { id: 'env1', managerId: 'manager1' }, - // }; - // mockGetEnvironment.resolves(mockEnvironment); - - // // Mock the getPackages function to return a list of packages - // const mockPackages: Package[] = [ - // { - // pkgId: { id: 'pkg1', managerId: 'pip', environmentId: 'env1' }, - // name: 'package1', - // displayName: 'package1', - // }, - // { - // pkgId: { id: 'pkg2', managerId: 'pip', environmentId: 'env1' }, - // name: 'package2', - // displayName: 'package2', - // }, - // ]; - // mockGetPackages.resolves(mockPackages); - - // tool.invoke(options, token); - - // tokenSource.cancel(); - // await assert.rejects(tool.invoke(options, token), { message: 'Operation cancelled' }); - // }); + test('should handle cancellation', async () => { + const testFile: IGetActiveFile = { + filePath: 'test.py', + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockEnvironment.setup((x: any) => x.then).returns(() => undefined); + + mockApi + .setup((x) => x.getEnvironment(typeMoq.It.isAny())) + .returns(async () => { + return Promise.resolve(mockEnvironment.object); + }); + + mockApi.setup((x) => x.refreshPackages(typeMoq.It.isAny())).returns(() => Promise.resolve()); + mockApi.setup((x) => x.getPackages(typeMoq.It.isAny())).returns(() => Promise.resolve([])); + + const options = { input: testFile, toolInvocationToken: undefined }; + const tokenSource = new vscode.CancellationTokenSource(); + const token = tokenSource.token; + + const deferred = createDeferred(); + tool.invoke(options, token).then((result) => { + const content = result.content as vscode.LanguageModelTextPart[]; + const firstPart = content[0] as vscode.MarkdownString; + + assert.strictEqual(firstPart.value, 'Operation cancelled by the user.'); + deferred.resolve(); + }); + + tokenSource.cancel(); + await deferred.promise; + }); }); From cb8f3580d1198c15e16bd0508daaea565f11ae0f Mon Sep 17 00:00:00 2001 From: eleanorjboyd Date: Wed, 22 Jan 2025 13:35:07 -0800 Subject: [PATCH 05/11] rename call for tool --- package.json | 4 ++-- src/extension.ts | 14 ++------------ 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index f1ac64c2..df923456 100644 --- a/package.json +++ b/package.json @@ -427,10 +427,10 @@ ], "languageModelTools": [ { - "name": "python_get_python_packages", + "name": "python_get_packages", "displayName": "Get Python Packages", "modelDescription": "Given a file path, finds the workspace and selected python environment for this file, and returns the list of installed packages.", - "toolReferenceName": "pythonGetPythonPackages", + "toolReferenceName": "pythonGetPackages", "tags": [], "icon": "$(files)", "inputSchema": { diff --git a/src/extension.ts b/src/extension.ts index 2d2d1c6e..59cb6fba 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -105,12 +105,7 @@ export async function activate(context: ExtensionContext): Promise outputChannel.show()), commands.registerCommand('python-envs.refreshManager', async (item) => { await refreshManagerCommand(item); @@ -243,12 +238,7 @@ export async function activate(context: ExtensionContext): Promise Date: Wed, 22 Jan 2025 13:37:33 -0800 Subject: [PATCH 06/11] enable vscode-editing --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index df923456..40b785e7 100644 --- a/package.json +++ b/package.json @@ -431,7 +431,9 @@ "displayName": "Get Python Packages", "modelDescription": "Given a file path, finds the workspace and selected python environment for this file, and returns the list of installed packages.", "toolReferenceName": "pythonGetPackages", - "tags": [], + "tags": [ + "vscode_editing" + ], "icon": "$(files)", "inputSchema": { "type": "object", From 1805b25de6740947c53141ede41ae159db7bfa94 Mon Sep 17 00:00:00 2001 From: eleanorjboyd Date: Thu, 23 Jan 2025 08:36:27 -0800 Subject: [PATCH 07/11] allow tool to be callable by vscode tools --- package.json | 7 +- vscode.proposed.chatParticipantAdditions.d.ts | 397 ++++++++++++++++++ vscode.proposed.chatParticipantPrivate.d.ts | 125 ++++++ vscode.proposed.chatVariableResolver.d.ts | 110 +++++ 4 files changed, 638 insertions(+), 1 deletion(-) create mode 100644 vscode.proposed.chatParticipantAdditions.d.ts create mode 100644 vscode.proposed.chatParticipantPrivate.d.ts create mode 100644 vscode.proposed.chatVariableResolver.d.ts diff --git a/package.json b/package.json index 40b785e7..0ae598eb 100644 --- a/package.json +++ b/package.json @@ -495,5 +495,10 @@ "stack-trace": "0.0.10", "vscode-jsonrpc": "^9.0.0-next.5", "which": "^4.0.0" - } + }, + "enabledApiProposals": [ + "chatParticipantPrivate", + "chatParticipantAdditions", + "chatVariableResolver" + ] } \ No newline at end of file diff --git a/vscode.proposed.chatParticipantAdditions.d.ts b/vscode.proposed.chatParticipantAdditions.d.ts new file mode 100644 index 00000000..1be58f20 --- /dev/null +++ b/vscode.proposed.chatParticipantAdditions.d.ts @@ -0,0 +1,397 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + export interface ChatParticipant { + onDidPerformAction: Event; + } + + /** + * Now only used for the "intent detection" API below + */ + export interface ChatCommand { + readonly name: string; + readonly description: string; + } + + export class ChatResponseDetectedParticipantPart { + participant: string; + // TODO@API validate this against statically-declared slash commands? + command?: ChatCommand; + constructor(participant: string, command?: ChatCommand); + } + + export interface ChatVulnerability { + title: string; + description: string; + // id: string; // Later we will need to be able to link these across multiple content chunks. + } + + export class ChatResponseMarkdownWithVulnerabilitiesPart { + value: MarkdownString; + vulnerabilities: ChatVulnerability[]; + constructor(value: string | MarkdownString, vulnerabilities: ChatVulnerability[]); + } + + export class ChatResponseCodeblockUriPart { + value: Uri; + constructor(value: Uri); + } + + /** + * Displays a {@link Command command} as a button in the chat response. + */ + export interface ChatCommandButton { + command: Command; + } + + export interface ChatDocumentContext { + uri: Uri; + version: number; + ranges: Range[]; + } + + export class ChatResponseTextEditPart { + uri: Uri; + edits: TextEdit[]; + isDone?: boolean; + constructor(uri: Uri, done: true); + constructor(uri: Uri, edits: TextEdit | TextEdit[]); + } + + export class ChatResponseConfirmationPart { + title: string; + message: string; + data: any; + buttons?: string[]; + constructor(title: string, message: string, data: any, buttons?: string[]); + } + + export class ChatResponseCodeCitationPart { + value: Uri; + license: string; + snippet: string; + constructor(value: Uri, license: string, snippet: string); + } + + export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseDetectedParticipantPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart; + + export class ChatResponseWarningPart { + value: MarkdownString; + constructor(value: string | MarkdownString); + } + + export class ChatResponseProgressPart2 extends ChatResponseProgressPart { + value: string; + task?: (progress: Progress) => Thenable; + constructor(value: string, task?: (progress: Progress) => Thenable); + } + + export class ChatResponseReferencePart2 { + /** + * The reference target. + */ + value: Uri | Location | { variableName: string; value?: Uri | Location } | string; + + /** + * The icon for the reference. + */ + iconPath?: Uri | ThemeIcon | { + /** + * The icon path for the light theme. + */ + light: Uri; + /** + * The icon path for the dark theme. + */ + dark: Uri; + }; + options?: { status?: { description: string; kind: ChatResponseReferencePartStatusKind } }; + + /** + * Create a new ChatResponseReferencePart. + * @param value A uri or location + * @param iconPath Icon for the reference shown in UI + */ + constructor(value: Uri | Location | { variableName: string; value?: Uri | Location } | string, iconPath?: Uri | ThemeIcon | { + /** + * The icon path for the light theme. + */ + light: Uri; + /** + * The icon path for the dark theme. + */ + dark: Uri; + }, options?: { status?: { description: string; kind: ChatResponseReferencePartStatusKind } }); + } + + export class ChatResponseMovePart { + + readonly uri: Uri; + readonly range: Range; + + constructor(uri: Uri, range: Range); + } + + export interface ChatResponseAnchorPart { + /** + * The target of this anchor. + * + * If this is a {@linkcode Uri} or {@linkcode Location}, this is rendered as a normal link. + * + * If this is a {@linkcode SymbolInformation}, this is rendered as a symbol link. + * + * TODO mjbvz: Should this be a full `SymbolInformation`? Or just the parts we need? + * TODO mjbvz: Should we allow a `SymbolInformation` without a location? For example, until `resolve` completes? + */ + value2: Uri | Location | SymbolInformation; + + /** + * Optional method which fills in the details of the anchor. + * + * THis is currently only implemented for symbol links. + */ + resolve?(token: CancellationToken): Thenable; + } + + export interface ChatResponseStream { + + /** + * Push a progress part to this stream. Short-hand for + * `push(new ChatResponseProgressPart(value))`. + * + * @param value A progress message + * @param task If provided, a task to run while the progress is displayed. When the Thenable resolves, the progress will be marked complete in the UI, and the progress message will be updated to the resolved string if one is specified. + * @returns This stream. + */ + progress(value: string, task?: (progress: Progress) => Thenable): void; + + textEdit(target: Uri, edits: TextEdit | TextEdit[]): void; + + textEdit(target: Uri, isDone: true): void; + + markdownWithVulnerabilities(value: string | MarkdownString, vulnerabilities: ChatVulnerability[]): void; + codeblockUri(uri: Uri): void; + detectedParticipant(participant: string, command?: ChatCommand): void; + push(part: ChatResponsePart | ChatResponseTextEditPart | ChatResponseDetectedParticipantPart | ChatResponseWarningPart | ChatResponseProgressPart2): void; + + /** + * Show an inline message in the chat view asking the user to confirm an action. + * Multiple confirmations may be shown per response. The UI might show "Accept All" / "Reject All" actions. + * @param title The title of the confirmation entry + * @param message An extra message to display to the user + * @param data An arbitrary JSON-stringifiable object that will be included in the ChatRequest when + * the confirmation is accepted or rejected + * TODO@API should this be MarkdownString? + * TODO@API should actually be a more generic function that takes an array of buttons + */ + confirmation(title: string, message: string, data: any, buttons?: string[]): void; + + /** + * Push a warning to this stream. Short-hand for + * `push(new ChatResponseWarningPart(message))`. + * + * @param message A warning message + * @returns This stream. + */ + warning(message: string | MarkdownString): void; + + reference(value: Uri | Location | { variableName: string; value?: Uri | Location }, iconPath?: Uri | ThemeIcon | { light: Uri; dark: Uri }): void; + + reference2(value: Uri | Location | string | { variableName: string; value?: Uri | Location }, iconPath?: Uri | ThemeIcon | { light: Uri; dark: Uri }, options?: { status?: { description: string; kind: ChatResponseReferencePartStatusKind } }): void; + + codeCitation(value: Uri, license: string, snippet: string): void; + + push(part: ExtendedChatResponsePart): void; + } + + export enum ChatResponseReferencePartStatusKind { + Complete = 1, + Partial = 2, + Omitted = 3 + } + + /** + * Does this piggy-back on the existing ChatRequest, or is it a different type of request entirely? + * Does it show up in history? + */ + export interface ChatRequest { + /** + * The `data` for any confirmations that were accepted + */ + acceptedConfirmationData?: any[]; + + /** + * The `data` for any confirmations that were rejected + */ + rejectedConfirmationData?: any[]; + } + + // TODO@API fit this into the stream + export interface ChatUsedContext { + documents: ChatDocumentContext[]; + } + + export interface ChatParticipant { + /** + * Provide a set of variables that can only be used with this participant. + */ + participantVariableProvider?: { provider: ChatParticipantCompletionItemProvider; triggerCharacters: string[] }; + } + + export interface ChatParticipantCompletionItemProvider { + provideCompletionItems(query: string, token: CancellationToken): ProviderResult; + } + + export class ChatCompletionItem { + id: string; + label: string | CompletionItemLabel; + values: ChatVariableValue[]; + fullName?: string; + icon?: ThemeIcon; + insertText?: string; + detail?: string; + documentation?: string | MarkdownString; + command?: Command; + + constructor(id: string, label: string | CompletionItemLabel, values: ChatVariableValue[]); + } + + export type ChatExtendedRequestHandler = (request: ChatRequest, context: ChatContext, response: ChatResponseStream, token: CancellationToken) => ProviderResult; + + export interface ChatResult { + nextQuestion?: { + prompt: string; + participant?: string; + command?: string; + }; + } + + export namespace chat { + /** + * Create a chat participant with the extended progress type + */ + export function createChatParticipant(id: string, handler: ChatExtendedRequestHandler): ChatParticipant; + + export function registerChatParticipantDetectionProvider(participantDetectionProvider: ChatParticipantDetectionProvider): Disposable; + } + + export interface ChatParticipantMetadata { + participant: string; + command?: string; + disambiguation: { category: string; description: string; examples: string[] }[]; + } + + export interface ChatParticipantDetectionResult { + participant: string; + command?: string; + } + + export interface ChatParticipantDetectionProvider { + provideParticipantDetection(chatRequest: ChatRequest, context: ChatContext, options: { participants?: ChatParticipantMetadata[]; location: ChatLocation }, token: CancellationToken): ProviderResult; + } + + /* + * User action events + */ + + export enum ChatCopyKind { + // Keyboard shortcut or context menu + Action = 1, + Toolbar = 2 + } + + export interface ChatCopyAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'copy'; + codeBlockIndex: number; + copyKind: ChatCopyKind; + copiedCharacters: number; + totalCharacters: number; + copiedText: string; + } + + export interface ChatInsertAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'insert'; + codeBlockIndex: number; + totalCharacters: number; + newFile?: boolean; + } + + export interface ChatApplyAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'apply'; + codeBlockIndex: number; + totalCharacters: number; + newFile?: boolean; + codeMapper?: string; + } + + export interface ChatTerminalAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'runInTerminal'; + codeBlockIndex: number; + languageId?: string; + } + + export interface ChatCommandAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'command'; + commandButton: ChatCommandButton; + } + + export interface ChatFollowupAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'followUp'; + followup: ChatFollowup; + } + + export interface ChatBugReportAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'bug'; + } + + export interface ChatEditorAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'editor'; + accepted: boolean; + } + + export interface ChatEditingSessionAction { + // eslint-disable-next-line local/vscode-dts-string-type-literals + kind: 'chatEditingSessionAction'; + uri: Uri; + hasRemainingEdits: boolean; + outcome: ChatEditingSessionActionOutcome; + } + + export enum ChatEditingSessionActionOutcome { + Accepted = 1, + Rejected = 2, + Saved = 3 + } + + export interface ChatUserActionEvent { + readonly result: ChatResult; + readonly action: ChatCopyAction | ChatInsertAction | ChatApplyAction | ChatTerminalAction | ChatCommandAction | ChatFollowupAction | ChatBugReportAction | ChatEditorAction | ChatEditingSessionAction; + } + + export interface ChatPromptReference { + /** + * TODO Needed for now to drive the variableName-type reference, but probably both of these should go away in the future. + */ + readonly name: string; + } + + export interface ChatResultFeedback { + readonly unhelpfulReason?: string; + } + + export namespace lm { + export function fileIsIgnored(uri: Uri, token: CancellationToken): Thenable; + } +} diff --git a/vscode.proposed.chatParticipantPrivate.d.ts b/vscode.proposed.chatParticipantPrivate.d.ts new file mode 100644 index 00000000..4f08ef3a --- /dev/null +++ b/vscode.proposed.chatParticipantPrivate.d.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// version: 2 + +declare module 'vscode' { + + /** + * The location at which the chat is happening. + */ + export enum ChatLocation { + /** + * The chat panel + */ + Panel = 1, + /** + * Terminal inline chat + */ + Terminal = 2, + /** + * Notebook inline chat + */ + Notebook = 3, + /** + * Code editor inline chat + */ + Editor = 4, + /** + * Chat is happening in an editing session + */ + EditingSession = 5, + } + + export class ChatRequestEditorData { + //TODO@API should be the editor + document: TextDocument; + selection: Selection; + wholeRange: Range; + + constructor(document: TextDocument, selection: Selection, wholeRange: Range); + } + + export class ChatRequestNotebookData { + //TODO@API should be the editor + readonly cell: TextDocument; + + constructor(cell: TextDocument); + } + + export interface ChatRequest { + /** + * The attempt number of the request. The first request has attempt number 0. + */ + readonly attempt: number; + + /** + * If automatic command detection is enabled. + */ + readonly enableCommandDetection: boolean; + + /** + * If the chat participant or command was automatically assigned. + */ + readonly isParticipantDetected: boolean; + + /** + * The location at which the chat is happening. This will always be one of the supported values + * + * @deprecated + */ + readonly location: ChatLocation; + + /** + * Information that is specific to the location at which chat is happening, e.g within a document, notebook, + * or terminal. Will be `undefined` for the chat panel. + */ + readonly location2: ChatRequestEditorData | ChatRequestNotebookData | undefined; + } + + export interface ChatParticipant { + supportIssueReporting?: boolean; + + /** + * Temp, support references that are slow to resolve and should be tools rather than references. + */ + supportsSlowReferences?: boolean; + } + + export interface ChatErrorDetails { + /** + * If set to true, the message content is completely hidden. Only ChatErrorDetails#message will be shown. + */ + responseIsRedacted?: boolean; + + isQuotaExceeded?: boolean; + } + + export namespace chat { + export function createDynamicChatParticipant(id: string, dynamicProps: DynamicChatParticipantProps, handler: ChatExtendedRequestHandler): ChatParticipant; + } + + /** + * These don't get set on the ChatParticipant after creation, like other props, because they are typically defined in package.json and we want them at the time of creation. + */ + export interface DynamicChatParticipantProps { + name: string; + publisherName: string; + description?: string; + fullName?: string; + } + + export namespace lm { + export function registerIgnoredFileProvider(provider: LanguageModelIgnoredFileProvider): Disposable; + } + + export interface LanguageModelIgnoredFileProvider { + provideFileIgnored(uri: Uri, token: CancellationToken): ProviderResult; + } + + export interface LanguageModelToolInvocationOptions { + chatRequestId?: string; + } +} diff --git a/vscode.proposed.chatVariableResolver.d.ts b/vscode.proposed.chatVariableResolver.d.ts new file mode 100644 index 00000000..ec386ec9 --- /dev/null +++ b/vscode.proposed.chatVariableResolver.d.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + export namespace chat { + + /** + * Register a variable which can be used in a chat request to any participant. + * @param id A unique ID for the variable. + * @param name The name of the variable, to be used in the chat input as `#name`. + * @param userDescription A description of the variable for the chat input suggest widget. + * @param modelDescription A description of the variable for the model. + * @param isSlow Temp, to limit access to '#codebase' which is not a 'reference' and will fit into a tools API later. + * @param resolver Will be called to provide the chat variable's value when it is used. + * @param fullName The full name of the variable when selecting context in the picker UI. + * @param icon An icon to display when selecting context in the picker UI. + */ + export function registerChatVariableResolver(id: string, name: string, userDescription: string, modelDescription: string | undefined, isSlow: boolean | undefined, resolver: ChatVariableResolver, fullName?: string, icon?: ThemeIcon): Disposable; + } + + export interface ChatVariableValue { + /** + * The detail level of this chat variable value. If possible, variable resolvers should try to offer shorter values that will consume fewer tokens in an LLM prompt. + */ + level: ChatVariableLevel; + + /** + * The variable's value, which can be included in an LLM prompt as-is, or the chat participant may decide to read the value and do something else with it. + */ + value: string | Uri; + + /** + * A description of this value, which could be provided to the LLM as a hint. + */ + description?: string; + } + + // TODO@API align with ChatRequest + export interface ChatVariableContext { + /** + * The message entered by the user, which includes this variable. + */ + // TODO@API AS-IS, variables as types, agent/commands stripped + prompt: string; + + // readonly variables: readonly ChatResolvedVariable[]; + } + + export interface ChatVariableResolver { + /** + * A callback to resolve the value of a chat variable. + * @param name The name of the variable. + * @param context Contextual information about this chat request. + * @param token A cancellation token. + */ + resolve(name: string, context: ChatVariableContext, token: CancellationToken): ProviderResult; + + /** + * A callback to resolve the value of a chat variable. + * @param name The name of the variable. + * @param context Contextual information about this chat request. + * @param token A cancellation token. + */ + resolve2?(name: string, context: ChatVariableContext, stream: ChatVariableResolverResponseStream, token: CancellationToken): ProviderResult; + } + + + /** + * The detail level of this chat variable value. + */ + export enum ChatVariableLevel { + Short = 1, + Medium = 2, + Full = 3 + } + + export interface ChatVariableResolverResponseStream { + /** + * Push a progress part to this stream. Short-hand for + * `push(new ChatResponseProgressPart(value))`. + * + * @param value + * @returns This stream. + */ + progress(value: string): ChatVariableResolverResponseStream; + + /** + * Push a reference to this stream. Short-hand for + * `push(new ChatResponseReferencePart(value))`. + * + * *Note* that the reference is not rendered inline with the response. + * + * @param value A uri or location + * @returns This stream. + */ + reference(value: Uri | Location): ChatVariableResolverResponseStream; + + /** + * Pushes a part to this stream. + * + * @param part A response part, rendered or metadata + */ + push(part: ChatVariableResolverResponsePart): ChatVariableResolverResponseStream; + } + + export type ChatVariableResolverResponsePart = ChatResponseProgressPart | ChatResponseReferencePart; +} From d15354b9a6a4e36c55cd878f30bc1e8e4248369f Mon Sep 17 00:00:00 2001 From: eleanorjboyd Date: Thu, 23 Jan 2025 08:38:06 -0800 Subject: [PATCH 08/11] remove duplicate register --- src/extension.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 59cb6fba..f80370a7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -237,8 +237,6 @@ export async function activate(context: ExtensionContext): Promise Date: Thu, 23 Jan 2025 11:42:44 -0800 Subject: [PATCH 09/11] add support for pkg versions --- src/copilotTools.ts | 4 ++- src/test/copilotTools.unit.test.ts | 47 +++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/copilotTools.ts b/src/copilotTools.ts index c7e29d81..414be7c5 100644 --- a/src/copilotTools.ts +++ b/src/copilotTools.ts @@ -59,7 +59,9 @@ export class GetPackagesTool implements LanguageModelTool { if (!installedPackages || installedPackages.length === 0) { resultMessage = 'No packages are installed in the current environment.'; } else { - const packageNames = installedPackages.map((pkg) => pkg.name).join(', '); + const packageNames = installedPackages + .map((pkg) => pkg.version ? `${pkg.name} (${pkg.version})` : pkg.name) + .join(', '); resultMessage = 'The packages installed in the current environment are as follows:\n' + packageNames; } diff --git a/src/test/copilotTools.unit.test.ts b/src/test/copilotTools.unit.test.ts index ff03331a..70d23376 100644 --- a/src/test/copilotTools.unit.test.ts +++ b/src/test/copilotTools.unit.test.ts @@ -104,7 +104,7 @@ suite('GetPackagesTool Tests', () => { assert.strictEqual(firstPart.value, 'No packages are installed in the current environment.'); }); - test('should return installed packages', async () => { + test('should return just packages if versions do not exist', async () => { const testFile: IGetActiveFile = { filePath: 'test.py', }; @@ -147,6 +147,51 @@ suite('GetPackagesTool Tests', () => { ); }); + test('should return installed packages with versions', async () => { + const testFile: IGetActiveFile = { + filePath: 'test.py', + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockEnvironment.setup((x: any) => x.then).returns(() => undefined); + + mockApi + .setup((x) => x.getEnvironment(typeMoq.It.isAny())) + .returns(() => { + return Promise.resolve(mockEnvironment.object); + }); + + const mockPackages: Package[] = [ + { + pkgId: { id: 'pkg1', managerId: 'pip', environmentId: 'env1' }, + name: 'package1', + displayName: 'package1', + version: '1.0.0', + }, + { + pkgId: { id: 'pkg2', managerId: 'pip', environmentId: 'env1' }, + name: 'package2', + displayName: 'package2', + version: '2.0.0', + }, + ]; + + mockApi.setup((x) => x.refreshPackages(typeMoq.It.isAny())).returns(() => Promise.resolve()); + mockApi.setup((x) => x.getPackages(typeMoq.It.isAny())).returns(() => Promise.resolve(mockPackages)); + + const options = { input: testFile, toolInvocationToken: undefined }; + const token = new vscode.CancellationTokenSource().token; + const result = await tool.invoke(options, token); + const content = result.content as vscode.LanguageModelTextPart[]; + const firstPart = content[0] as vscode.MarkdownString; + + assert.ok( + firstPart.value.includes('The packages installed in the current environment are as follows:') && + firstPart.value.includes('package1 (1.0.0)') && + firstPart.value.includes('package2 (2.0.0)'), + ); + }); + test('should handle cancellation', async () => { const testFile: IGetActiveFile = { filePath: 'test.py', From ee16cd7764f3a566e322756a0dde9def4783191b Mon Sep 17 00:00:00 2001 From: eleanorjboyd Date: Thu, 23 Jan 2025 11:50:42 -0800 Subject: [PATCH 10/11] update model description --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8f30e9be..2f7cff27 100644 --- a/package.json +++ b/package.json @@ -449,7 +449,7 @@ { "name": "python_get_packages", "displayName": "Get Python Packages", - "modelDescription": "Given a file path, finds the workspace and selected python environment for this file, and returns the list of installed packages.", + "modelDescription": "Returns the packages installed in the given Python file's environment. You should call this when you want to generate Python code to determine the users preferred packages. Also call this to determine if you need to provide installation instructions in a response.", "toolReferenceName": "pythonGetPackages", "tags": [ "vscode_editing" From ace514615c60e31f4b7c814845611fdc752e87f5 Mon Sep 17 00:00:00 2001 From: eleanorjboyd Date: Thu, 23 Jan 2025 11:52:13 -0800 Subject: [PATCH 11/11] add description --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 2f7cff27..a4c84c4f 100644 --- a/package.json +++ b/package.json @@ -462,6 +462,7 @@ "type": "string" } }, + "description": "The path to the Python file or workspace to get the installed packages for.", "required": [ "filePath" ]