diff --git a/package.json b/package.json index 30a9fb237c20..f51dea6e26e4 100644 --- a/package.json +++ b/package.json @@ -1593,7 +1593,7 @@ { "name": "configure_python_environment", "displayName": "Configure Python Environment", - "modelDescription": "This tool configures a Python environment in the given workspace. ALWAYS Use this tool to set up the user's chosen environment and ALWAYS call this tool before using any other Python related tools or running any Python command in the terminal. IMPORTANT: This tool is only for Python environments (venv, virtualenv, conda, pipenv, poetry, pyenv, pixi, or any other Python environment manager). Do not use this tool for npm packages, system packages, Ruby gems, or any other non-Python dependencies.", + "modelDescription": "This tool configures a Python environment in the given workspace. ALWAYS Use this tool to set up the user's chosen environment and ALWAYS call this tool before using any other Python related tools or running any Python command in the terminal. If you already know which Python interpreter to use (e.g. from a previous tool call or user message), pass it as 'pythonPath' to skip interactive prompts and configure the environment automatically. IMPORTANT: This tool is only for Python environments (venv, virtualenv, conda, pipenv, poetry, pyenv, pixi, or any other Python environment manager). Do not use this tool for npm packages, system packages, Ruby gems, or any other non-Python dependencies.", "userDescription": "%python.languageModelTools.configure_python_environment.userDescription%", "toolReferenceName": "configurePythonEnvironment", "tags": [ @@ -1609,6 +1609,10 @@ "resourcePath": { "type": "string", "description": "The path to the Python file or workspace for which a Python Environment needs to be configured." + }, + "pythonPath": { + "type": "string", + "description": "Optional absolute path to a Python interpreter to use. When provided, the environment is configured automatically without any interactive prompts. Use this to avoid blocking the session on user input." } }, "required": [] diff --git a/src/client/chat/configurePythonEnvTool.ts b/src/client/chat/configurePythonEnvTool.ts index 914a92f81c52..2f07e05c4a24 100644 --- a/src/client/chat/configurePythonEnvTool.ts +++ b/src/client/chat/configurePythonEnvTool.ts @@ -23,15 +23,28 @@ import { IResourceReference, isCancellationError, raceCancellationError, + setEnvironmentDirectlyByPath, } from './utils'; import { ITerminalHelper } from '../common/terminal/types'; import { IRecommendedEnvironmentService } from '../interpreter/configuration/types'; import { CreateVirtualEnvTool } from './createVirtualEnvTool'; import { ISelectPythonEnvToolArguments, SelectPythonEnvTool } from './selectEnvTool'; import { BaseTool } from './baseTool'; +import { traceVerbose } from '../logging'; +import { ErrorWithTelemetrySafeReason } from '../common/errors/errorUtils'; -export class ConfigurePythonEnvTool extends BaseTool - implements LanguageModelTool { +export interface IConfigurePythonEnvToolArguments extends IResourceReference { + /** + * Optional path to a Python interpreter. When provided, the tool sets this + * interpreter directly without any user interaction (no Quick Pick, no + * create-venv prompt). This is the recommended way for Copilot to call + * the tool in autopilot / bypass-approvals mode. + */ + pythonPath?: string; +} + +export class ConfigurePythonEnvTool extends BaseTool + implements LanguageModelTool { private readonly terminalExecutionService: TerminalCodeExecutionProvider; private readonly terminalHelper: ITerminalHelper; private readonly recommendedEnvService: IRecommendedEnvironmentService; @@ -53,7 +66,7 @@ export class ConfigurePythonEnvTool extends BaseTool } async invokeImpl( - options: LanguageModelToolInvocationOptions, + options: LanguageModelToolInvocationOptions, resource: Uri | undefined, token: CancellationToken, ): Promise { @@ -63,6 +76,11 @@ export class ConfigurePythonEnvTool extends BaseTool return notebookResponse; } + // Fast path: if the caller provided a pythonPath, set it directly without any UI. + if (options.input.pythonPath) { + return this.setEnvironmentDirectly(options.input.pythonPath, resource, token); + } + const workspaceSpecificEnv = await raceCancellationError( this.hasAlreadyGotAWorkspaceSpecificEnvironment(resource), token, @@ -107,8 +125,37 @@ export class ConfigurePythonEnvTool extends BaseTool } } + /** + * Sets the given interpreter path directly without user interaction, then + * resolves and returns the environment details. + */ + private async setEnvironmentDirectly( + pythonPath: string, + resource: Uri | undefined, + token: CancellationToken, + ): Promise { + traceVerbose(`${ConfigurePythonEnvTool.toolName}: setting environment directly from pythonPath: ${pythonPath}`); + const result = await setEnvironmentDirectlyByPath(pythonPath, this.api, resource, token); + if (result) { + this.extraTelemetryProperties.resolveOutcome = 'providedEnv'; + this.extraTelemetryProperties.envType = getEnvTypeForTelemetry(result); + return getEnvDetailsForResponse( + result, + this.api, + this.terminalExecutionService, + this.terminalHelper, + resource, + token, + ); + } + throw new ErrorWithTelemetrySafeReason( + `No environment found for the provided pythonPath '${pythonPath}'.`, + 'noEnvFound', + ); + } + async prepareInvocationImpl( - _options: LanguageModelToolInvocationPrepareOptions, + _options: LanguageModelToolInvocationPrepareOptions, _resource: Uri | undefined, _token: CancellationToken, ): Promise { diff --git a/src/client/chat/createVirtualEnvTool.ts b/src/client/chat/createVirtualEnvTool.ts index 56760d2b4bef..096067a8f779 100644 --- a/src/client/chat/createVirtualEnvTool.ts +++ b/src/client/chat/createVirtualEnvTool.ts @@ -45,7 +45,7 @@ import { hideEnvCreation } from '../pythonEnvironments/creation/provider/hideEnv import { BaseTool } from './baseTool'; interface ICreateVirtualEnvToolParams extends IResourceReference { - packageList?: string[]; // Added only becausewe have ability to create a virtual env with list of packages same tool within the in Python Env extension. + packageList?: string[]; // Added only because we have the ability to create a virtual env with a list of packages using the same tool within the Python Env extension. } export class CreateVirtualEnvTool extends BaseTool @@ -92,12 +92,17 @@ export class CreateVirtualEnvTool extends BaseTool let createdEnvPath: string | undefined = undefined; if (useEnvExtension()) { - const result: PythonEnvironment | undefined = await commands.executeCommand('python-envs.createAny', { - quickCreate: true, - additionalPackages: options.input.packageList || [], - uri: workspaceFolder.uri, - selectEnvironment: true, - }); + const result: PythonEnvironment | undefined = await raceCancellationError( + Promise.resolve( + commands.executeCommand('python-envs.createAny', { + quickCreate: true, + additionalPackages: options.input.packageList || [], + uri: workspaceFolder.uri, + selectEnvironment: true, + }), + ), + token, + ); createdEnvPath = result?.environmentPath.fsPath; } else { const created = await raceCancellationError( @@ -116,19 +121,19 @@ export class CreateVirtualEnvTool extends BaseTool // Wait a few secs to ensure the env is selected as the active environment.. // If this doesn't work, then something went wrong. - await raceTimeout(5_000, interpreterChanged); + await raceCancellationError(raceTimeout(5_000, interpreterChanged), token); const stopWatch = new StopWatch(); let env: ResolvedEnvironment | undefined; - while (stopWatch.elapsedTime < 5_000 || !env) { - env = await this.api.resolveEnvironment(createdEnvPath); + while (stopWatch.elapsedTime < 5_000 && !env) { + env = await raceCancellationError(this.api.resolveEnvironment(createdEnvPath), token); if (env) { break; } else { traceVerbose( `${CreateVirtualEnvTool.toolName} tool invoked, env created but not yet resolved, waiting...`, ); - await sleep(200); + await raceCancellationError(sleep(200), token); } } if (!env) { diff --git a/src/client/chat/selectEnvTool.ts b/src/client/chat/selectEnvTool.ts index 9eeebdfc1b56..834bcd315944 100644 --- a/src/client/chat/selectEnvTool.ts +++ b/src/client/chat/selectEnvTool.ts @@ -25,6 +25,7 @@ import { getEnvDetailsForResponse, getToolResponseIfNotebook, IResourceReference, + raceCancellationError, } from './utils'; import { ITerminalHelper } from '../common/terminal/types'; import { raceTimeout } from '../common/utils/async'; @@ -67,12 +68,15 @@ export class SelectPythonEnvTool extends BaseTool let selected: boolean | undefined = false; const hasVenvOrCondaEnvInWorkspaceFolder = doesWorkspaceHaveVenvOrCondaEnv(resource, this.api); if (options.input.reason === 'cancelled' || hasVenvOrCondaEnvInWorkspaceFolder) { - const result = (await Promise.resolve( - commands.executeCommand(Commands.Set_Interpreter, { - hideCreateVenv: false, - showBackButton: false, - }), - )) as SelectEnvironmentResult | undefined; + const result = await raceCancellationError( + Promise.resolve( + commands.executeCommand(Commands.Set_Interpreter, { + hideCreateVenv: false, + showBackButton: false, + }), + ) as Promise, + token, + ); if (result?.path) { traceVerbose(`User selected a Python environment ${result.path} in Select Python Tool.`); selected = true; @@ -80,7 +84,10 @@ export class SelectPythonEnvTool extends BaseTool traceWarn(`User did not select a Python environment in Select Python Tool.`); } } else { - selected = await showCreateAndSelectEnvironmentQuickPick(resource, this.serviceContainer); + selected = await raceCancellationError( + showCreateAndSelectEnvironmentQuickPick(resource, this.serviceContainer, token), + token, + ); if (selected) { traceVerbose(`User selected a Python environment ${selected} in Select Python Tool(2).`); } else { @@ -152,6 +159,7 @@ export class SelectPythonEnvTool extends BaseTool async function showCreateAndSelectEnvironmentQuickPick( uri: Uri | undefined, serviceContainer: IServiceContainer, + token: CancellationToken, ): Promise { const createLabel = `${Octicons.Add} ${InterpreterQuickPickList.create.label}`; const selectLabel = l10n.t('Select an existing Python Environment'); @@ -161,11 +169,15 @@ async function showCreateAndSelectEnvironmentQuickPick( { label: selectLabel }, ]; - const selectedItem = await showQuickPick(items, { - placeHolder: l10n.t('Configure a Python Environment'), - matchOnDescription: true, - ignoreFocusOut: true, - }); + const selectedItem = await showQuickPick( + items, + { + placeHolder: l10n.t('Configure a Python Environment'), + matchOnDescription: true, + ignoreFocusOut: true, + }, + token, + ); if (selectedItem && !Array.isArray(selectedItem) && selectedItem.label === createLabel) { const disposables = new DisposableStore(); @@ -187,7 +199,7 @@ async function showCreateAndSelectEnvironmentQuickPick( ); if (created?.action === 'Back') { - return showCreateAndSelectEnvironmentQuickPick(uri, serviceContainer); + return showCreateAndSelectEnvironmentQuickPick(uri, serviceContainer, token); } if (created?.action === 'Cancel') { return undefined; @@ -206,7 +218,7 @@ async function showCreateAndSelectEnvironmentQuickPick( commands.executeCommand(Commands.Set_Interpreter, { hideCreateVenv: true, showBackButton: true }), )) as SelectEnvironmentResult | undefined; if (result?.action === 'Back') { - return showCreateAndSelectEnvironmentQuickPick(uri, serviceContainer); + return showCreateAndSelectEnvironmentQuickPick(uri, serviceContainer, token); } if (result?.action === 'Cancel') { return undefined; diff --git a/src/client/chat/utils.ts b/src/client/chat/utils.ts index 2309316bcbdd..7778fe6b27b5 100644 --- a/src/client/chat/utils.ts +++ b/src/client/chat/utils.ts @@ -20,6 +20,7 @@ import { dirname, join } from 'path'; import { resolveEnvironment, useEnvExtension } from '../envExt/api.internal'; import { ErrorWithTelemetrySafeReason } from '../common/errors/errorUtils'; import { getWorkspaceFolders } from '../common/vscodeApis/workspaceApis'; +import { arePathsSame } from '../common/platform/fs-paths'; export interface IResourceReference { resourcePath?: string; @@ -49,15 +50,125 @@ export function resolveFilePath(filepath?: string): Uri | undefined { * @see {@link raceCancellation} */ export function raceCancellationError(promise: Promise, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + return Promise.reject(new CancellationError()); + } return new Promise((resolve, reject) => { const ref = token.onCancellationRequested(() => { ref.dispose(); reject(new CancellationError()); }); - promise.then(resolve, reject).finally(() => ref.dispose()); + promise.then( + (value) => { + ref.dispose(); + resolve(value); + }, + (error) => { + ref.dispose(); + reject(error); + }, + ); }); } +/** + * Returns a promise that resolves once the active environment path changes to match the + * provided `pythonPath` (matched against either the event's `path` or `id`). Resolves early + * on cancellation or after `timeoutMs` to avoid hanging callers if the event is missed. + * Callers must subscribe via this helper BEFORE invoking `updateActiveEnvironmentPath` to + * avoid a race where the event fires before the listener is attached. + */ +export function waitForActiveEnvironmentChange( + api: PythonExtension['environments'], + pythonPath: string, + resource: Uri | undefined, + token: CancellationToken, + timeoutMs = 5000, +): Promise { + if (token.isCancellationRequested) { + return Promise.resolve(); + } + return new Promise((resolve) => { + let settled = false; + const listener = api.onDidChangeActiveEnvironmentPath((e) => { + if (isEnvironmentPathMatch(e, pythonPath) && isResourceMatch(e.resource, resource)) { + settle(); + } + }); + const cancelRef = token.onCancellationRequested(() => settle()); + const timer = setTimeout(() => settle(), timeoutMs); + function settle() { + if (settled) { + return; + } + settled = true; + listener.dispose(); + cancelRef.dispose(); + clearTimeout(timer); + resolve(); + } + }); +} + +function isResourceMatch(eventResource: { uri: Uri } | Uri | undefined, requestedResource: Uri | undefined): boolean { + const eventUri = eventResource && 'uri' in eventResource ? eventResource.uri : eventResource; + const requestedUri = requestedResource + ? workspace.getWorkspaceFolder(requestedResource)?.uri ?? requestedResource + : undefined; + return eventUri === undefined + ? requestedUri === undefined + : requestedUri !== undefined && arePathsSame(eventUri.fsPath, requestedUri.fsPath); +} + +function isEnvironmentPathMatch(environment: { path: string; id: string }, pythonPath: string): boolean { + return arePathsSame(environment.path, pythonPath) || environment.id === pythonPath; +} + +/** + * Sets the active Python interpreter to `pythonPath` without any UI, waits for the + * asynchronous environment switch to settle (via `onDidChangeActiveEnvironmentPath`), + * resolves the environment, and returns it. + * + * Returns `undefined` if the path cannot be resolved to a valid environment so callers + * can produce a tool-specific error message. + */ +export async function setEnvironmentDirectlyByPath( + pythonPath: string, + api: PythonExtension['environments'], + resource: Uri | undefined, + token: CancellationToken, +): Promise { + if (token.isCancellationRequested) { + throw new CancellationError(); + } + // Validate the path resolves to a real environment BEFORE mutating user settings. + // updateActiveEnvironmentPath persists unconditionally, so an invalid path would + // permanently overwrite the user's selected interpreter. + const candidate = await raceCancellationError(api.resolveEnvironment(pythonPath), token); + if (!candidate) { + return undefined; + } + if (isEnvironmentPathMatch(api.getActiveEnvironmentPath(resource), pythonPath)) { + return candidate; + } + + // Subscribe to the change event BEFORE triggering the update so we don't miss it. + // updateActiveEnvironmentPath only persists the setting; the active interpreter switch + // is asynchronous, so we wait for the event before resolving env details to avoid + // returning details for the previously-active interpreter. + const activeChanged = waitForActiveEnvironmentChange(api, pythonPath, resource, token); + await raceCancellationError(api.updateActiveEnvironmentPath(pythonPath, resource), token); + await raceCancellationError(activeChanged, token); + + // Verify the active env actually switched. If the change event timed out and the + // active path is still the previous one, don't report success for the wrong env. + const envPath = api.getActiveEnvironmentPath(resource); + if (!isEnvironmentPathMatch(envPath, pythonPath)) { + return undefined; + } + return raceCancellationError(api.resolveEnvironment(envPath), token); +} + export async function getEnvDisplayName( discovery: IDiscoveryAPI, resource: Uri | undefined, diff --git a/src/test/chat/setEnvironmentFastPath.unit.test.ts b/src/test/chat/setEnvironmentFastPath.unit.test.ts new file mode 100644 index 000000000000..2377e09577fd --- /dev/null +++ b/src/test/chat/setEnvironmentFastPath.unit.test.ts @@ -0,0 +1,317 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { + CancellationError, + CancellationTokenSource, + EventEmitter, + LanguageModelToolInvocationOptions, + Uri, +} from 'vscode'; +import { instance, mock, when } from 'ts-mockito'; +import { ConfigurePythonEnvTool, IConfigurePythonEnvToolArguments } from '../../client/chat/configurePythonEnvTool'; +import { setEnvironmentDirectlyByPath, waitForActiveEnvironmentChange } from '../../client/chat/utils'; +import { ActiveEnvironmentPathChangeEvent, PythonExtension, ResolvedEnvironment } from '../../client/api/types'; +import { IServiceContainer } from '../../client/ioc/types'; +import { ICodeExecutionService } from '../../client/terminals/types'; +import { ITerminalHelper, TerminalShellType } from '../../client/common/terminal/types'; +import { IRecommendedEnvironmentService } from '../../client/interpreter/configuration/types'; +import { TerminalCodeExecutionProvider } from '../../client/terminals/codeExecution/terminalCodeExecution'; +import { CreateVirtualEnvTool } from '../../client/chat/createVirtualEnvTool'; +import { mockedVSCodeNamespaces } from '../vscode-mock'; + +suite('Chat fast-path environment setup', () => { + const pythonPath = '/usr/bin/python3'; + const environment = ({ + id: 'python-env', + path: pythonPath, + executable: { uri: Uri.file(pythonPath), bitness: 64, sysPrefix: '/usr' }, + version: { major: 3, minor: 13, micro: 0, release: { level: 'final', serial: 0 }, sysVersion: '3.13.0' }, + environment: { type: 'Venv' }, + } as unknown) as ResolvedEnvironment; + let tokenSource: CancellationTokenSource; + + function getToolResultText(content: readonly unknown[]): string { + return content + .map((part) => + typeof part === 'object' && part !== null && 'value' in part && typeof part.value === 'string' + ? part.value + : '', + ) + .join(' '); + } + + setup(() => { + tokenSource = new CancellationTokenSource(); + when(mockedVSCodeNamespaces.workspace!.notebookDocuments).thenReturn([]); + when(mockedVSCodeNamespaces.workspace!.isTrusted).thenReturn(true); + }); + + teardown(() => { + tokenSource.dispose(); + sinon.restore(); + }); + + suite('waitForActiveEnvironmentChange()', () => { + function makeApi(emitter: EventEmitter) { + return ({ onDidChangeActiveEnvironmentPath: emitter.event } as unknown) as PythonExtension['environments']; + } + + test('resolves when an event matches the requested path', async () => { + const emitter = new EventEmitter(); + const promise = waitForActiveEnvironmentChange(makeApi(emitter), pythonPath, undefined, tokenSource.token); + let settled = false; + void promise.then(() => { + settled = true; + }); + + emitter.fire({ path: pythonPath, id: 'python-env', resource: undefined }); + await Promise.resolve(); + + expect(settled).to.equal(true); + }); + + test('resolves when an event matches the requested environment id', async () => { + const emitter = new EventEmitter(); + const promise = waitForActiveEnvironmentChange( + makeApi(emitter), + 'python-env', + undefined, + tokenSource.token, + ); + let settled = false; + void promise.then(() => { + settled = true; + }); + + emitter.fire({ path: pythonPath, id: 'python-env', resource: undefined }); + await Promise.resolve(); + + expect(settled).to.equal(true); + }); + + test('resolves when an event matches an equivalent normalized path', async () => { + const emitter = new EventEmitter(); + const promise = waitForActiveEnvironmentChange(makeApi(emitter), pythonPath, undefined, tokenSource.token); + let settled = false; + void promise.then(() => { + settled = true; + }); + + emitter.fire({ path: '/usr/bin/../bin/python3', id: 'other-env', resource: undefined }); + await Promise.resolve(); + + expect(settled).to.equal(true); + }); + + test('resolves on cancellation', async () => { + const emitter = new EventEmitter(); + const promise = waitForActiveEnvironmentChange( + makeApi(emitter), + pythonPath, + undefined, + tokenSource.token, + 60_000, + ); + let settled = false; + void promise.then(() => { + settled = true; + }); + + tokenSource.cancel(); + await Promise.resolve(); + + expect(settled).to.equal(true); + }); + + test('ignores non-matching events', async () => { + const emitter = new EventEmitter(); + const promise = waitForActiveEnvironmentChange( + makeApi(emitter), + pythonPath, + undefined, + tokenSource.token, + 60_000, + ); + let settled = false; + void promise.then(() => { + settled = true; + }); + + emitter.fire({ path: '/other/python', id: 'other-env', resource: undefined }); + await Promise.resolve(); + + expect(settled).to.equal(false); + tokenSource.cancel(); + await promise; + }); + + test('ignores matching environment events for another workspace', async () => { + const emitter = new EventEmitter(); + const resource = Uri.file('/workspace-one'); + const promise = waitForActiveEnvironmentChange( + makeApi(emitter), + pythonPath, + resource, + tokenSource.token, + 60_000, + ); + let settled = false; + void promise.then(() => { + settled = true; + }); + + emitter.fire({ path: pythonPath, id: 'python-env', resource: Uri.file('/workspace-two') }); + await Promise.resolve(); + + expect(settled).to.equal(false); + emitter.fire({ path: pythonPath, id: 'python-env', resource }); + await promise; + }); + }); + + suite('setEnvironmentDirectlyByPath()', () => { + test('validates before updating and returns the newly active environment', async () => { + const emitter = new EventEmitter(); + const calls: string[] = []; + let listenerAttached = false; + let activePath = { path: '/old/python', id: 'old-env' }; + const api = ({ + onDidChangeActiveEnvironmentPath: (handler: (event: ActiveEnvironmentPathChangeEvent) => void) => { + listenerAttached = true; + return emitter.event(handler); + }, + updateActiveEnvironmentPath: async (path: string) => { + expect(listenerAttached).to.equal(true); + calls.push(`update:${path}`); + activePath = { path, id: 'python-env' }; + setImmediate(() => emitter.fire({ path, id: 'python-env', resource: undefined })); + }, + getActiveEnvironmentPath: () => activePath, + resolveEnvironment: async (value: string | { path: string }) => { + calls.push(`resolve:${typeof value === 'string' ? value : value.path}`); + return environment; + }, + } as unknown) as PythonExtension['environments']; + + const result = await setEnvironmentDirectlyByPath(pythonPath, api, undefined, tokenSource.token); + + expect(result).to.equal(environment); + expect(calls).to.deep.equal([`resolve:${pythonPath}`, `update:${pythonPath}`, `resolve:${pythonPath}`]); + }); + + test('does not update settings when the path cannot be resolved', async () => { + const update = sinon.stub().resolves(); + const api = ({ + onDidChangeActiveEnvironmentPath: new EventEmitter().event, + updateActiveEnvironmentPath: update, + getActiveEnvironmentPath: () => ({ path: '/old/python', id: 'old-env' }), + resolveEnvironment: sinon.stub().resolves(undefined), + } as unknown) as PythonExtension['environments']; + + const result = await setEnvironmentDirectlyByPath('/invalid/python', api, undefined, tokenSource.token); + + expect(result).to.equal(undefined); + sinon.assert.notCalled(update); + }); + + test('does not update settings when the requested environment is already active', async () => { + const update = sinon.stub().resolves(); + const api = ({ + updateActiveEnvironmentPath: update, + getActiveEnvironmentPath: () => ({ path: pythonPath, id: 'python-env' }), + resolveEnvironment: sinon.stub().resolves(environment), + } as unknown) as PythonExtension['environments']; + + const result = await setEnvironmentDirectlyByPath(pythonPath, api, undefined, tokenSource.token); + + expect(result).to.equal(environment); + sinon.assert.notCalled(update); + }); + + test('does not report the previous environment when the active path fails to switch', async () => { + const emitter = new EventEmitter(); + const resolveEnvironment = sinon.stub(); + resolveEnvironment.onFirstCall().resolves(environment); + const api = ({ + onDidChangeActiveEnvironmentPath: emitter.event, + updateActiveEnvironmentPath: async () => { + setImmediate(() => emitter.fire({ path: '/new/python', id: 'new-env', resource: undefined })); + }, + getActiveEnvironmentPath: () => ({ path: '/old/python', id: 'old-env' }), + resolveEnvironment, + } as unknown) as PythonExtension['environments']; + + const result = await setEnvironmentDirectlyByPath('/new/python', api, undefined, tokenSource.token); + + expect(result).to.equal(undefined); + sinon.assert.calledOnce(resolveEnvironment); + }); + + test('rejects immediately when already cancelled', async () => { + const resolveEnvironment = sinon.stub().resolves(environment); + const api = ({ resolveEnvironment } as unknown) as PythonExtension['environments']; + tokenSource.cancel(); + + let error: unknown; + try { + await setEnvironmentDirectlyByPath(pythonPath, api, undefined, tokenSource.token); + } catch (ex) { + error = ex; + } + + expect(error).to.be.instanceOf(CancellationError); + sinon.assert.notCalled(resolveEnvironment); + }); + }); + + test('configure tool skips interactive environment selection when pythonPath is provided', async () => { + const resource = Uri.file('/workspace/file.py'); + const getRecommendedEnvironment = sinon.stub().resolves(undefined); + const shouldCreateNewVirtualEnv = sinon.stub().resolves(false); + const terminalExecutionService = mock(); + const terminalHelper = mock(); + when(terminalExecutionService.getExecutableInfo(resource)).thenResolve({ + command: 'python', + args: [], + python: ['python'], + pythonExecutable: 'python', + }); + when(terminalHelper.buildCommandForTerminal(TerminalShellType.other, 'python', [])).thenReturn('python'); + const serviceContainer = mock(); + when(serviceContainer.get(ICodeExecutionService, 'standard')).thenReturn( + instance(terminalExecutionService), + ); + when(serviceContainer.get(ITerminalHelper)).thenReturn(instance(terminalHelper)); + when(serviceContainer.get(IRecommendedEnvironmentService)).thenReturn(({ + getRecommededEnvironment: getRecommendedEnvironment, + } as unknown) as IRecommendedEnvironmentService); + const emitter = new EventEmitter(); + let activePath = { path: '/old/python', id: 'old-env' }; + const api = ({ + onDidChangeActiveEnvironmentPath: emitter.event, + updateActiveEnvironmentPath: async (path: string) => { + activePath = { path, id: 'python-env' }; + setImmediate(() => emitter.fire({ path, id: 'python-env', resource })); + }, + getActiveEnvironmentPath: () => activePath, + resolveEnvironment: sinon.stub().resolves(environment), + } as unknown) as PythonExtension['environments']; + const createVenvTool = ({ shouldCreateNewVirtualEnv } as unknown) as CreateVirtualEnvTool; + const tool = new ConfigurePythonEnvTool(api, instance(serviceContainer), createVenvTool); + const options = ({ + input: { pythonPath }, + } as unknown) as LanguageModelToolInvocationOptions; + + const result = await tool.invokeImpl(options, resource, tokenSource.token); + + const text = getToolResultText(result.content); + expect(text).to.include('A Python Environment has been configured'); + sinon.assert.notCalled(getRecommendedEnvironment); + sinon.assert.notCalled(shouldCreateNewVirtualEnv); + }); +}); diff --git a/src/test/vscode-mock.ts b/src/test/vscode-mock.ts index b7ea2bc549a0..ac64384520cf 100644 --- a/src/test/vscode-mock.ts +++ b/src/test/vscode-mock.ts @@ -149,6 +149,12 @@ mockedVSCode.TestRunProfileKind = vscodeMocks.TestRunProfileKind; (mockedVSCode as any).StatementCoverage = class StatementCoverage { constructor(public executed: number | boolean, public location: any, public branches?: any) {} }; +(mockedVSCode as any).LanguageModelTextPart = class LanguageModelTextPart { + constructor(public value: string) {} +}; +(mockedVSCode as any).LanguageModelToolResult = class LanguageModelToolResult { + constructor(public content: unknown[]) {} +}; // Mock TestController for vscode.tests namespace function createMockTestController(): vscode.TestController {