diff --git a/package.json b/package.json index 9f689b60ff34..b3997e3b9eaf 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": [] @@ -1642,7 +1646,7 @@ { "name": "selectEnvironment", "displayName": "Select a Python Environment", - "modelDescription": "This tool will prompt the user to select an existing Python Environment", + "modelDescription": "This tool will prompt the user to select an existing Python Environment. If pythonPath is provided, it sets that interpreter directly without showing any UI.", "tags": [], "canBeReferencedInPrompt": false, "inputSchema": { @@ -1651,6 +1655,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 interpreter is set directly without showing any UI picker." } }, "required": [] diff --git a/src/client/chat/configurePythonEnvTool.ts b/src/client/chat/configurePythonEnvTool.ts index 914a92f81c52..ccfef202de9a 100644 --- a/src/client/chat/configurePythonEnvTool.ts +++ b/src/client/chat/configurePythonEnvTool.ts @@ -23,15 +23,27 @@ 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'; -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 +65,7 @@ export class ConfigurePythonEnvTool extends BaseTool } async invokeImpl( - options: LanguageModelToolInvocationOptions, + options: LanguageModelToolInvocationOptions, resource: Uri | undefined, token: CancellationToken, ): Promise { @@ -63,6 +75,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 +124,32 @@ 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, + this.terminalExecutionService, + this.terminalHelper, + resource, + token, + ); + if (result) { + return result; + } + throw new Error(`No environment found for the provided pythonPath '${pythonPath}'.`); + } + 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..6af825bccbdb 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,18 +92,24 @@ 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( createVirtualEnvironment({ interpreter: preferredGlobalPythonEnv.id, workspaceFolder, + installPackages: false, }), token, ); @@ -120,7 +126,7 @@ export class CreateVirtualEnvTool extends BaseTool const stopWatch = new StopWatch(); let env: ResolvedEnvironment | undefined; - while (stopWatch.elapsedTime < 5_000 || !env) { + while (stopWatch.elapsedTime < 5_000 && !env) { env = await this.api.resolveEnvironment(createdEnvPath); if (env) { break; diff --git a/src/client/chat/selectEnvTool.ts b/src/client/chat/selectEnvTool.ts index 9eeebdfc1b56..9f9b443dc213 100644 --- a/src/client/chat/selectEnvTool.ts +++ b/src/client/chat/selectEnvTool.ts @@ -25,6 +25,8 @@ import { getEnvDetailsForResponse, getToolResponseIfNotebook, IResourceReference, + raceCancellationError, + setEnvironmentDirectlyByPath, } from './utils'; import { ITerminalHelper } from '../common/terminal/types'; import { raceTimeout } from '../common/utils/async'; @@ -40,6 +42,13 @@ import { BaseTool } from './baseTool'; export interface ISelectPythonEnvToolArguments extends IResourceReference { reason?: 'cancelled'; + /** + * Optional path to a Python interpreter. When provided, the tool sets this + * interpreter directly without showing any Quick Pick UI to the user. + * This prevents the agent from getting stuck waiting for user input in + * autopilot / bypass-approvals mode. + */ + pythonPath?: string; } export class SelectPythonEnvTool extends BaseTool @@ -64,15 +73,46 @@ export class SelectPythonEnvTool extends BaseTool resource: Uri | undefined, token: CancellationToken, ): Promise { + const notebookResponse = getToolResponseIfNotebook(resource); + if (notebookResponse) { + return notebookResponse; + } + + // Fast path: if the caller provided a pythonPath, set it directly without any UI. + if (options.input.pythonPath) { + traceVerbose( + `${SelectPythonEnvTool.toolName}: setting environment directly from pythonPath: ${options.input.pythonPath}`, + ); + const result = await setEnvironmentDirectlyByPath( + options.input.pythonPath, + this.api, + this.terminalExecutionService, + this.terminalHelper, + resource, + token, + ); + if (result) { + return result; + } + return new LanguageModelToolResult([ + new LanguageModelTextPart( + `The provided pythonPath '${options.input.pythonPath}' could not be resolved to a valid Python environment.`, + ), + ]); + } + 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 +120,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, + ); if (selected) { traceVerbose(`User selected a Python environment ${selected} in Select Python Tool(2).`); } else { @@ -120,6 +163,12 @@ export class SelectPythonEnvTool extends BaseTool if (getToolResponseIfNotebook(resource)) { return {}; } + // Fast path: skip the confirmation prompt when the model has already supplied + // a specific interpreter to use. Showing a confirmation here would defeat the + // purpose of the autopilot/bypass-approvals fast path. + if (options.input.pythonPath) { + return {}; + } const hasVenvOrCondaEnvInWorkspaceFolder = doesWorkspaceHaveVenvOrCondaEnv(resource, this.api); if ( diff --git a/src/client/chat/utils.ts b/src/client/chat/utils.ts index 2309316bcbdd..6deb53960d26 100644 --- a/src/client/chat/utils.ts +++ b/src/client/chat/utils.ts @@ -58,6 +58,86 @@ export function raceCancellationError(promise: Promise, token: Cancellatio }); } +/** + * 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, + token: CancellationToken, + timeoutMs = 5000, +): Promise { + return new Promise((resolve) => { + let settled = false; + const listener = api.onDidChangeActiveEnvironmentPath((e) => { + if (e.path === pythonPath || e.id === pythonPath) { + 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(); + } + }); +} + +/** + * 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 a tool result describing 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'], + terminalExecutionService: TerminalCodeExecutionProvider, + terminalHelper: ITerminalHelper, + resource: Uri | undefined, + token: CancellationToken, +): Promise { + // 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; + } + + // 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, 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 (envPath.path !== pythonPath && envPath.id !== pythonPath) { + return undefined; + } + const environment = await raceCancellationError(api.resolveEnvironment(envPath), token); + if (!environment) { + return undefined; + } + return getEnvDetailsForResponse(environment, api, terminalExecutionService, terminalHelper, resource, 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..fbed453fbca4 --- /dev/null +++ b/src/test/chat/setEnvironmentFastPath.unit.test.ts @@ -0,0 +1,299 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { CancellationTokenSource, EventEmitter, Uri } from 'vscode'; +import { instance, mock, when } from 'ts-mockito'; +import { mockedVSCodeNamespaces } from '../vscode-mock'; +import { setEnvironmentDirectlyByPath, waitForActiveEnvironmentChange } from '../../client/chat/utils'; +import { ConfigurePythonEnvTool } from '../../client/chat/configurePythonEnvTool'; +import { SelectPythonEnvTool } from '../../client/chat/selectEnvTool'; +import { PythonExtension } from '../../client/api/types'; +import { IServiceContainer } from '../../client/ioc/types'; +import { ICodeExecutionService } from '../../client/terminals/types'; +import { ITerminalHelper } 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'; + +suite('Chat fast-path environment setup', () => { + let tokenSource: CancellationTokenSource; + + setup(() => { + tokenSource = new CancellationTokenSource(); + when(mockedVSCodeNamespaces.workspace!.notebookDocuments).thenReturn([]); + }); + + teardown(() => { + tokenSource.dispose(); + sinon.restore(); + }); + + suite('waitForActiveEnvironmentChange()', () => { + function makeApi(emitter: EventEmitter<{ path: string; id: string; resource: undefined }>) { + return ({ + onDidChangeActiveEnvironmentPath: emitter.event, + } as unknown) as PythonExtension['environments']; + } + + test('resolves when an event matches the requested path', async () => { + const emitter = new EventEmitter<{ path: string; id: string; resource: undefined }>(); + const api = makeApi(emitter); + const promise = waitForActiveEnvironmentChange(api, '/usr/bin/python3', tokenSource.token, 5000); + emitter.fire({ path: '/usr/bin/python3', id: 'id-1', resource: undefined }); + await promise; + }); + + test('resolves when an event matches the requested id', async () => { + const emitter = new EventEmitter<{ path: string; id: string; resource: undefined }>(); + const api = makeApi(emitter); + const promise = waitForActiveEnvironmentChange(api, 'env-id-42', tokenSource.token, 5000); + emitter.fire({ path: '/some/other/path', id: 'env-id-42', resource: undefined }); + await promise; + }); + + test('resolves on cancellation without firing the event', async () => { + const emitter = new EventEmitter<{ path: string; id: string; resource: undefined }>(); + const api = makeApi(emitter); + const promise = waitForActiveEnvironmentChange(api, '/never/fires', tokenSource.token, 60_000); + tokenSource.cancel(); + await promise; + }); + + test('resolves on timeout when the event never fires', async () => { + const emitter = new EventEmitter<{ path: string; id: string; resource: undefined }>(); + const api = makeApi(emitter); + await waitForActiveEnvironmentChange(api, '/never/fires', tokenSource.token, 5); + }); + + test('ignores events that do not match', async () => { + const emitter = new EventEmitter<{ path: string; id: string; resource: undefined }>(); + const api = makeApi(emitter); + const promise = waitForActiveEnvironmentChange(api, '/want/this', tokenSource.token, 50); + emitter.fire({ path: '/something/else', id: 'wrong-id', resource: undefined }); + // Should fall through to the timeout rather than resolve from the non-matching event. + await promise; + }); + }); + + suite('setEnvironmentDirectlyByPath()', () => { + test('validates path, subscribes BEFORE update, then resolves the active env', async () => { + const emitter = new EventEmitter<{ path: string; id: string; resource: undefined }>(); + let listenerAttached = false; + const calls: string[] = []; + const api = ({ + onDidChangeActiveEnvironmentPath: (handler: (e: any) => void) => { + listenerAttached = true; + return emitter.event(handler); + }, + updateActiveEnvironmentPath: async (p: string) => { + calls.push(`update:${p}`); + expect(listenerAttached, 'listener must be attached before update').to.equal(true); + // Fire the event asynchronously, as the real API would. + setImmediate(() => emitter.fire({ path: p, id: p, resource: undefined })); + }, + getActiveEnvironmentPath: () => ({ path: '/usr/bin/python3', id: 'id-1' }), + resolveEnvironment: async (arg: any) => { + const key = typeof arg === 'string' ? arg : arg?.path; + calls.push(`resolve:${key}`); + // Validation call (string arg) must succeed so the rest of the sequence runs. + // Post-switch call (EnvironmentPath object) returns undefined to keep this test + // focused on sequencing without exercising getEnvDetailsForResponse internals. + if (typeof arg === 'string') { + return ({ id: 'x' } as unknown) as undefined; + } + return undefined; + }, + } as unknown) as PythonExtension['environments']; + + const result = await setEnvironmentDirectlyByPath( + '/usr/bin/python3', + api, + instance(mock()), + instance(mock()), + undefined, + tokenSource.token, + ); + + expect(result).to.equal(undefined); + expect(listenerAttached, 'listener must have been attached').to.equal(true); + // Full sequence: validate (resolve) -> update -> resolve active env. + expect(calls).to.deep.equal([ + 'resolve:/usr/bin/python3', + 'update:/usr/bin/python3', + 'resolve:/usr/bin/python3', + ]); + }); + + test('does NOT call updateActiveEnvironmentPath when pythonPath cannot be resolved', async () => { + let updateCalled = false; + const api = ({ + onDidChangeActiveEnvironmentPath: new EventEmitter().event, + updateActiveEnvironmentPath: async () => { + updateCalled = true; + }, + getActiveEnvironmentPath: () => ({ path: '/old', id: 'old' }), + resolveEnvironment: async () => undefined, + } as unknown) as PythonExtension['environments']; + + const result = await setEnvironmentDirectlyByPath( + '/bogus/python', + api, + instance(mock()), + instance(mock()), + undefined, + tokenSource.token, + ); + + expect(result).to.equal(undefined); + expect(updateCalled, 'must not mutate user settings for an invalid path').to.equal(false); + }); + + test('returns undefined when active path does not actually switch after update', async () => { + const emitter = new EventEmitter<{ path: string; id: string; resource: undefined }>(); + let postSwitchResolveCalled = false; + const api = ({ + onDidChangeActiveEnvironmentPath: emitter.event, + updateActiveEnvironmentPath: async (p: string) => { + // Fire the change event so waitForActiveEnvironmentChange resolves normally + // (no cancellation, no timeout) -- this drives execution past the wait into + // the post-switch verification block. + setImmediate(() => emitter.fire({ path: p, id: p, resource: undefined })); + }, + // But the getter still reports the OLD interpreter, simulating an inconsistent + // switch where the event fired but the active path didn't actually update. + getActiveEnvironmentPath: () => ({ path: '/previous', id: 'previous' }), + resolveEnvironment: async (arg: any) => { + if (typeof arg === 'string') { + // Validation passes (path is known). + return ({ id: 'x' } as unknown) as undefined; + } + postSwitchResolveCalled = true; + return undefined; + }, + } as unknown) as PythonExtension['environments']; + + const result = await setEnvironmentDirectlyByPath( + '/new/python', + api, + instance(mock()), + instance(mock()), + undefined, + tokenSource.token, + ); + + expect(result).to.equal(undefined); + expect(postSwitchResolveCalled, 'must not resolve / report details for the previously-active env').to.equal( + false, + ); + }); + }); + + suite('ConfigurePythonEnvTool fast path', () => { + test('skips workspace-env / create-venv path when pythonPath is provided', async () => { + const getRecommededEnvironment = sinon.stub().resolves(undefined); + const shouldCreateNewVirtualEnv = sinon.stub().resolves(false); + const serviceContainer = mock(); + when(serviceContainer.get(ICodeExecutionService, 'standard')).thenReturn( + instance(mock()), + ); + when(serviceContainer.get(ITerminalHelper)).thenReturn(instance(mock())); + when(serviceContainer.get(IRecommendedEnvironmentService)).thenReturn(({ + getRecommededEnvironment, + } as unknown) as IRecommendedEnvironmentService); + + const emitter = new EventEmitter<{ path: string; id: string; resource: undefined }>(); + let updateCalled = false; + const api = ({ + onDidChangeActiveEnvironmentPath: emitter.event, + updateActiveEnvironmentPath: async (p: string) => { + updateCalled = true; + setImmediate(() => emitter.fire({ path: p, id: p, resource: undefined })); + }, + getActiveEnvironmentPath: () => ({ path: '/usr/bin/python3', id: 'id-1' }), + // Validation (string arg) succeeds; post-switch resolve returns undefined + // so the helper exits without exercising getEnvDetailsForResponse. + resolveEnvironment: async (arg: any) => + typeof arg === 'string' ? (({ id: 'x' } as unknown) as undefined) : undefined, + } as unknown) as PythonExtension['environments']; + + const createVenvTool = ({ shouldCreateNewVirtualEnv } as unknown) as CreateVirtualEnvTool; + const tool = new ConfigurePythonEnvTool(api, instance(serviceContainer), createVenvTool); + + try { + await (tool as any).invokeImpl( + { input: { pythonPath: '/usr/bin/python3' } } as any, + Uri.file('/workspace/file.py'), + tokenSource.token, + ); + } catch { + // setEnvironmentDirectly throws when env can't be resolved; that's expected here. + // The behavior we care about is that the fast path was taken. + } + + expect(updateCalled, 'fast path should invoke updateActiveEnvironmentPath').to.equal(true); + // The recommended-env / create-venv branches must not have been consulted. + sinon.assert.notCalled(getRecommededEnvironment); + sinon.assert.notCalled(shouldCreateNewVirtualEnv); + }); + }); + + suite('SelectPythonEnvTool', () => { + test('returns notebook response without setting env when resource is a notebook', async () => { + const serviceContainer = mock(); + when(serviceContainer.get(ICodeExecutionService, 'standard')).thenReturn( + instance(mock()), + ); + when(serviceContainer.get(ITerminalHelper)).thenReturn(instance(mock())); + + let updateCalled = false; + const api = ({ + onDidChangeActiveEnvironmentPath: new EventEmitter().event, + updateActiveEnvironmentPath: async () => { + updateCalled = true; + }, + getActiveEnvironmentPath: () => ({ path: '/x', id: 'x' }), + resolveEnvironment: async () => undefined, + } as unknown) as PythonExtension['environments']; + + const tool = new SelectPythonEnvTool(api, instance(serviceContainer)); + + const result = await (tool as any).invokeImpl( + { input: { pythonPath: '/usr/bin/python3' } } as any, + Uri.file('/workspace/notebook.ipynb'), + tokenSource.token, + ); + + expect(updateCalled, 'must NOT update env for notebook resources').to.equal(false); + expect(result, 'notebook resources must produce a tool response').to.not.equal(undefined); + const text = (result.content as any[]) + .map((p) => (p && typeof p.value === 'string' ? p.value : '')) + .join(' '); + expect(text.toLowerCase()).to.include('notebook'); + }); + + test('prepareInvocationImpl skips confirmation when pythonPath is provided', async () => { + const serviceContainer = mock(); + when(serviceContainer.get(ICodeExecutionService, 'standard')).thenReturn( + instance(mock()), + ); + when(serviceContainer.get(ITerminalHelper)).thenReturn(instance(mock())); + + const api = ({ + onDidChangeActiveEnvironmentPath: new EventEmitter().event, + } as unknown) as PythonExtension['environments']; + const tool = new SelectPythonEnvTool(api, instance(serviceContainer)); + + const prep = await (tool as any).prepareInvocationImpl( + { input: { pythonPath: '/usr/bin/python3' } }, + Uri.file('/workspace/file.py'), + tokenSource.token, + ); + + expect(prep.confirmationMessages, 'fast path must not show a confirmation prompt').to.equal(undefined); + }); + }); +}); 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 {