diff --git a/package-lock.json b/package-lock.json index 8058ece5..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", @@ -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 03f3ad07..a4c84c4f 100644 --- a/package.json +++ b/package.json @@ -444,6 +444,31 @@ { "type": "python" } + ], + "languageModelTools": [ + { + "name": "python_get_packages", + "displayName": "Get Python 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" + ], + "icon": "$(files)", + "inputSchema": { + "type": "object", + "properties": { + "filePath": { + "type": "string" + } + }, + "description": "The path to the Python file or workspace to get the installed packages for.", + "required": [ + "filePath" + ] + }, + "canBeReferencedInPrompt": true + } ] }, "scripts": { @@ -465,7 +490,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", @@ -491,5 +516,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/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.ts b/src/copilotTools.ts new file mode 100644 index 00000000..414be7c5 --- /dev/null +++ b/src/copilotTools.ts @@ -0,0 +1,92 @@ +import { + CancellationToken, + LanguageModelTextPart, + LanguageModelTool, + LanguageModelToolInvocationOptions, + LanguageModelToolInvocationPrepareOptions, + LanguageModelToolResult, + PreparedToolInvocation, + Uri, +} from 'vscode'; +import { PythonPackageGetterApi, PythonProjectEnvironmentApi } from './api'; +import { createDeferred } from './common/utils/deferred'; + +export interface IGetActiveFile { + filePath?: string; +} + +/** + * A tool to get the list of installed Python packages in the active environment. + */ +export class GetPackagesTool implements LanguageModelTool { + 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. + * @param token - The cancellation token. + * @returns The result containing the list of installed packages or an error message. + */ + async invoke( + 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 === '') { + throw new Error('Invalid input: filePath is required'); + } + const fileUri = Uri.file(parameters.filePath); + + try { + 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#')) { + throw new Error('Unable to access Jupyter kernels for notebook cells'); + } + throw new Error('No environment found'); + } + await this.api.refreshPackages(environment); + const installedPackages = await this.api.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.version ? `${pkg.name} (${pkg.version})` : pkg.name) + .join(', '); + resultMessage = 'The packages installed in the current environment are as follows:\n' + packageNames; + } + + const textPart = new LanguageModelTextPart(resultMessage || ''); + deferredReturn.resolve({ content: [textPart] }); + } catch (error) { + const errorMessage: string = `An error occurred while fetching packages: ${error}`; + deferredReturn.resolve({ content: [new LanguageModelTextPart(errorMessage)] } as LanguageModelToolResult); + } + return deferredReturn.promise; + } + + /** + * Prepares the invocation of the tool. + * @param _options - The preparation options. + * @param _token - The cancellation token. + * @returns The prepared tool invocation. + */ + async prepareInvocation?( + _options: LanguageModelToolInvocationPrepareOptions, + _token: CancellationToken, + ): Promise { + const message = 'Preparing to fetch the list of installed Python packages...'; + return { + invocationMessage: message, + }; + } +} diff --git a/src/extension.ts b/src/extension.ts index 142da287..e4501d3a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -55,6 +55,8 @@ import { EventNames } from './common/telemetry/constants'; import { ensureCorrectVersion } from './common/extVersion'; import { ExistingProjects } from './features/creators/existingProjects'; import { AutoFindProjects } from './features/creators/autoFindProjects'; +import { GetPackagesTool } from './copilotTools'; +import { registerTools } from './common/lm.apis'; export async function activate(context: ExtensionContext): Promise { const start = new StopWatch(); @@ -103,6 +105,8 @@ export async function activate(context: ExtensionContext): Promise outputChannel.show()), commands.registerCommand('python-envs.refreshManager', async (item) => { await refreshManagerCommand(item); @@ -237,6 +241,8 @@ export async function activate(context: ExtensionContext): Promise { + let tool: GetPackagesTool; + let mockApi: typeMoq.IMock; + let mockEnvironment: typeMoq.IMock; + + setup(() => { + // Create mock functions + 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(mockApi.object); + }); + + teardown(() => { + sinon.restore(); + }); + + test('should throw error if filePath is undefined', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockEnvironment.setup((x: any) => x.then).returns(() => 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 () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockEnvironment.setup((x: any) => x.then).returns(() => 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.LanguageModelTextPart; + + 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 () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockEnvironment.setup((x: any) => x.then).returns(() => 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 () => { + 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 just packages if versions do not exist', 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.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 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', + }; + + // 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; + }); +}); 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/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); 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(); 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; +}