diff --git a/package.json b/package.json index ac2069faf..0426189f5 100644 --- a/package.json +++ b/package.json @@ -4573,6 +4573,14 @@ "advanced" ] }, + "github.copilot.chat.cli.thinkingEffort.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "%github.copilot.config.cli.thinkingEffort.enabled%", + "tags": [ + "advanced" + ] + }, "github.copilot.chat.cli.sessionControllerForSessionsApp.enabled": { "type": "boolean", "default": false, diff --git a/package.nls.json b/package.nls.json index fca78b714..110baee38 100644 --- a/package.nls.json +++ b/package.nls.json @@ -407,6 +407,7 @@ "github.copilot.config.cli.isolationOption.enabled": "Enable the isolation mode option for Copilot CLI. When enabled, users can choose between Worktree and Workspace modes.", "github.copilot.config.cli.autoCommit.enabled": "Enable automatic commit for Copilot CLI. When enabled, changes made by Copilot CLI will be automatically committed to the repository at the end of each turn.", "github.copilot.config.cli.sessionController.enabled": "Enable the new session controller API for Copilot CLI. Requires VS Code reload.", + "github.copilot.config.cli.thinkingEffort.enabled": "Enable thinking effort for Language Models in Copilot CLI.", "github.copilot.config.cli.sessionControllerForSessionsApp.enabled": "Enable the new session controller API for Sessions App. Requires VS Code reload.", "github.copilot.config.cli.terminalLinks.enabled": "Enable advanced clickable file links in Copilot CLI terminals. Resolves relative paths against session state directories. Requires VS Code reload.", "github.copilot.config.backgroundAgent.enabled": "Enable the Copilot CLI. When disabled, the Copilot CLI will not be available in 'Continue In' context menus.", diff --git a/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/src/extension/chatSessions/copilotcli/node/copilotCli.ts index da6c28199..2a73e5e84 100644 --- a/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { SessionOptions, SweCustomAgent } from '@github/copilot/sdk'; +import * as l10n from '@vscode/l10n'; import { promises as fs } from 'fs'; import * as path from 'path'; import type * as vscode from 'vscode'; @@ -26,6 +27,7 @@ import { getCopilotLogger } from './logger'; import { ensureNodePtyShim } from './nodePtyShim'; import { ensureRipgrepShim } from './ripgrepShim'; +export const COPILOT_CLI_REASONING_EFFORT_PROPERTY = 'reasoningEffort'; const COPILOT_CLI_MODEL_MEMENTO_KEY = 'github.copilot.cli.sessionModel'; const COPILOT_CLI_REQUEST_MAP_KEY = 'github.copilot.cli.requestMap'; // Store last used Agent for a Session. @@ -44,6 +46,9 @@ export interface CopilotCLIModelInfo { readonly maxOutputTokens?: number; readonly maxContextWindowTokens: number; readonly supportsVision?: boolean; + readonly supportsReasoningEffort?: boolean; + readonly defaultReasoningEffort?: string; + readonly supportedReasoningEfforts?: string[]; } export interface ICopilotCLIModels { @@ -124,6 +129,9 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { maxOutputTokens: model.capabilities.limits.max_output_tokens, maxContextWindowTokens: model.capabilities.limits.max_context_window_tokens, supportsVision: model.capabilities.supports.vision, + supportsReasoningEffort: model.capabilities.supports.reasoningEffort, + defaultReasoningEffort: model.defaultReasoningEffort, + supportedReasoningEfforts: model.supportedReasoningEfforts } satisfies CopilotCLIModelInfo)); } catch (ex) { this.logService.error(`[CopilotCLISession] Failed to fetch models`, ex); @@ -152,7 +160,7 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { private async _provideLanguageModelChatInfo(): Promise { const models = await this.getModels(); - return models.map((model, index) => { + const modelsInfo = models.map((model, index) => { const multiplier = model.multiplier === undefined ? undefined : `${model.multiplier}x`; return { id: model.id, @@ -164,14 +172,16 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { multiplier, multiplierNumeric: model.multiplier, isUserSelectable: true, + configurationSchema: buildConfigurationSchema(model), capabilities: { imageInput: model.supportsVision, - toolCalling: true + toolCalling: true, }, targetChatSessionType: 'copilotcli', isDefault: index === 0 // SDK guarantees the first item is the default model }; }); + return modelsInfo; } } @@ -181,6 +191,37 @@ export interface CLIAgentInfo { /** File URI for prompt-file agents, synthetic `copilotcli:` URI for SDK-only agents. */ readonly sourceUri: URI; } +function buildConfigurationSchema(modelInfo: CopilotCLIModelInfo): vscode.LanguageModelConfigurationSchema | undefined { + const effortLevels = modelInfo.supportedReasoningEfforts ?? []; + if (effortLevels.length === 0) { + return; + } + + const defaultEffort = modelInfo.defaultReasoningEffort; + + return { + properties: { + [COPILOT_CLI_REASONING_EFFORT_PROPERTY]: { + type: 'string', + title: l10n.t('Thinking Effort'), + enum: effortLevels, + enumItemLabels: effortLevels.map(level => level.charAt(0).toUpperCase() + level.slice(1)), + enumDescriptions: effortLevels.map(level => { + switch (level) { + case 'none': return l10n.t('No reasoning applied'); + case 'low': return l10n.t('Faster responses with less reasoning'); + case 'medium': return l10n.t('Balanced reasoning and speed'); + case 'high': return l10n.t('Greater reasoning depth but slower'); + case 'xhigh': return l10n.t('Maximum reasoning depth but slower'); + default: return level; + } + }), + default: defaultEffort, + group: 'navigation', + } + } + }; +} export interface ICopilotCLIAgents { readonly _serviceBrand: undefined; diff --git a/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 74b7e4f4b..a4ce2ec49 100644 --- a/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -86,7 +86,7 @@ export interface ICopilotCLISession extends IDisposable { request: { id: string; toolInvocationToken: ChatParticipantToolToken; sessionResource?: vscode.Uri }, input: CopilotCLISessionInput, attachments: Attachment[], - modelId: string | undefined, + model: { model: string; reasoningEffort?: string } | undefined, authInfo: NonNullable, token: vscode.CancellationToken ): Promise; @@ -213,7 +213,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes request: { id: string; toolInvocationToken: ChatParticipantToolToken; sessionResource?: vscode.Uri }, input: CopilotCLISessionInput, attachments: Attachment[], - modelId: string | undefined, + model: { model: string; reasoningEffort?: string } | undefined, authInfo: NonNullable, token: vscode.CancellationToken ): Promise { @@ -229,12 +229,12 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes const previousRequestSnapshot = this.previousRequest; const handled = this._requestLogger.captureInvocation(capturingToken, async () => { - await this.updateModel(modelId, authInfo, token); + await this.updateModel(model?.model, model?.reasoningEffort, authInfo, token); if (isAlreadyBusyWithAnotherRequest) { - return this._handleRequestSteering(input, attachments, modelId, previousRequestSnapshot, token); + return this._handleRequestSteering(input, attachments, model, previousRequestSnapshot, token); } else { - return this._handleRequestImpl(request, input, attachments, modelId, token); + return this._handleRequestImpl(request, input, attachments, model, token); } }); @@ -261,7 +261,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes private async _handleRequestSteering( input: CopilotCLISessionInput, attachments: Attachment[], - modelId: string | undefined, + model: { model: string; reasoningEffort?: string } | undefined, previousRequestPromise: Promise, token: vscode.CancellationToken, ): Promise { @@ -281,9 +281,9 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // previous request to finish, so this promise settles only once all // in-flight work is done. await Promise.all([previousRequestPromise, this.sendRequestInternal(input, attachments, true, logStartTime)]); - this._logConversation(prompt, '', modelId || '', attachments, logStartTime, 'Completed'); + this._logConversation(prompt, '', model?.model || '', attachments, logStartTime, 'Completed'); } catch (error) { - this._logConversation(prompt, '', modelId || '', attachments, logStartTime, 'Failed', error instanceof Error ? error.message : String(error)); + this._logConversation(prompt, '', model?.model || '', attachments, logStartTime, 'Failed', error instanceof Error ? error.message : String(error)); throw error; } finally { disposables.dispose(); @@ -294,9 +294,10 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes request: { id: string; toolInvocationToken: ChatParticipantToolToken }, input: CopilotCLISessionInput, attachments: Attachment[], - modelId: string | undefined, + model: { model: string; reasoningEffort?: string } | undefined, token: vscode.CancellationToken ): Promise { + const modelId = model?.model; return this._otelService.startActiveSpan( 'invoke_agent copilotcli', { @@ -766,7 +767,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } } - private async updateModel(modelId: string | undefined, authInfo: NonNullable, token: CancellationToken): Promise { + private async updateModel(modelId: string | undefined, reasoningEffort: string | undefined, authInfo: NonNullable, token: CancellationToken): Promise { // Where possible try to avoid an extra call to getSelectedModel by using cached value. let currentModel: string | undefined = undefined; if (modelId) { @@ -782,9 +783,13 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes if (authInfo) { this._sdkSession.setAuthInfo(authInfo); } - if (modelId && modelId !== currentModel) { - this._lastUsedModel = modelId; - await raceCancellation(this._sdkSession.setSelectedModel(modelId), token); + if (modelId) { + if (modelId !== currentModel) { + this._lastUsedModel = modelId; + await raceCancellation(this._sdkSession.setSelectedModel(modelId, reasoningEffort), token); + } else if (reasoningEffort && this._sdkSession.getReasoningEffort() !== reasoningEffort) { + await raceCancellation(this._sdkSession.setSelectedModel(modelId, reasoningEffort), token); + } } } diff --git a/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 02dd32ce0..aeef44fdd 100644 --- a/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -65,6 +65,7 @@ export interface ICopilotCLISessionItem { export type ExtendedChatRequest = ChatRequest & { prompt: string }; export type ISessionOptions = { model?: string; + reasoningEffort?: string; workspace: IWorkspaceInfo; agent?: SweCustomAgent; debugTargetSessionIds?: readonly string[]; @@ -526,34 +527,33 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } } - public async createSession({ model, workspace, agent, sessionId, debugTargetSessionIds, mcpServerMappings }: ICreateSessionOptions, token: CancellationToken): Promise { + public async createSession(options: ICreateSessionOptions, token: CancellationToken): Promise { const { mcpConfig: mcpServers, disposable: mcpGateway } = await this.mcpHandler.loadMcpConfig(); try { - const copilotUrl = this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl) || undefined; - const { agentName, sessionOptions } = await this.createSessionsOptions({ model, workspace, mcpServers, agent, copilotUrl, sessionId, debugTargetSessionIds, mcpServerMappings }); + const sessionOptions = await this.createSessionsOptions({ ...options, mcpServers }); const sessionManager = await raceCancellationError(this.getSessionManager(), token); - const sdkSession = await sessionManager.createSession({ ...sessionOptions, sessionId }); + const sdkSession = await sessionManager.createSession({ ...sessionOptions, sessionId: options.sessionId }); this._newSessionIds.delete(sdkSession.sessionId); // After the first session creation, the SDK's OTel TracerProvider is // initialized. Install the bridge processor so SDK-native spans flow // to the debug panel. this._installBridgeIfNeeded(); - if (copilotUrl) { + if (sessionOptions.copilotUrl) { sdkSession.setAuthInfo({ type: 'hmac', hmac: 'empty', host: 'https://github.com', copilotUser: { endpoints: { - api: copilotUrl + api: sessionOptions.copilotUrl } } }); } this.logService.trace(`[CopilotCLISession] Created new CopilotCLI session ${sdkSession.sessionId}.`); - const session = this.createCopilotSession(sdkSession, workspace, agentName, sessionManager); + const session = this.createCopilotSession(sdkSession, options.workspace, options.agent?.name, sessionManager); session.object.add(mcpGateway); return session; } @@ -638,7 +638,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS return false; } - protected async createSessionsOptions(options: { model?: string; workspace: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; agent: SweCustomAgent | undefined; copilotUrl?: string; sessionId?: string; debugTargetSessionIds?: readonly string[]; mcpServerMappings?: McpServerMappings }): Promise<{ readonly sessionOptions: Readonly; readonly agentName: string | undefined }> { + protected async createSessionsOptions(options: ICreateSessionOptions & { mcpServers?: SessionOptions['mcpServers'] }): Promise> { const [agentInfos, skillLocations] = await Promise.all([ this.agents.getAgents(), this.copilotCLISkills.getSkillsLocations(), @@ -680,34 +680,35 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS allOptions.customAgents = customAgents; } allOptions.enableStreaming = true; - if (options.copilotUrl) { - allOptions.copilotUrl = options.copilotUrl; + const copilotUrl = this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl) || undefined; + if (copilotUrl) { + allOptions.copilotUrl = copilotUrl; } if (systemMessage) { allOptions.systemMessage = systemMessage; } allOptions.sessionCapabilities = new Set(['plan-mode', 'memory', 'cli-documentation', 'ask-user', 'interactive-mode', 'system-notifications']); + if (options.reasoningEffort) { + allOptions.reasoningEffort = options.reasoningEffort; + } - return { - sessionOptions: allOptions as Readonly, - agentName: options.agent?.name, - }; + return allOptions as Readonly; } - public async getSession({ sessionId, model, workspace, agent, debugTargetSessionIds, mcpServerMappings }: IGetSessionOptions, token: CancellationToken): Promise { + public async getSession(options: IGetSessionOptions, token: CancellationToken): Promise { // https://github.com/microsoft/vscode/issues/276573 - const lock = this.sessionMutexForGetSession.get(sessionId) ?? new Mutex(); - this.sessionMutexForGetSession.set(sessionId, lock); + const lock = this.sessionMutexForGetSession.get(options.sessionId) ?? new Mutex(); + this.sessionMutexForGetSession.set(options.sessionId, lock); const lockDisposable = await lock.acquire(token); try { { - const session = this._sessionWrappers.get(sessionId); + const session = this._sessionWrappers.get(options.sessionId); if (session) { - this.logService.trace(`[CopilotCLISession] Reusing CopilotCLI session ${sessionId}.`); - this._partialSessionHistories.delete(sessionId); + this.logService.trace(`[CopilotCLISession] Reusing CopilotCLI session ${options.sessionId}.`); + this._partialSessionHistories.delete(options.sessionId); session.acquire(); - if (agent) { - await session.object.sdkSession.selectCustomAgent(agent.name); + if (options.agent) { + await session.object.sdkSession.selectCustomAgent(options.agent.name); } else { session.object.sdkSession.clearCustomAgent(); } @@ -720,16 +721,15 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS this.mcpHandler.loadMcpConfig(), ]); try { - const copilotUrl = this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl) || undefined; - const { agentName, sessionOptions } = await this.createSessionsOptions({ model, agent, workspace: workspace, mcpServers, copilotUrl, sessionId, debugTargetSessionIds, mcpServerMappings }); + const sessionOptions = await this.createSessionsOptions({ ...options, mcpServers }); - const sdkSession = await sessionManager.getSession({ ...sessionOptions, sessionId }, true); + const sdkSession = await sessionManager.getSession({ ...sessionOptions, sessionId: options.sessionId }, true); if (!sdkSession) { - this.logService.error(`[CopilotCLISession] CopilotCLI failed to get session ${sessionId}.`); + this.logService.error(`[CopilotCLISession] CopilotCLI failed to get session ${options.sessionId}.`); return undefined; } - const session = this.createCopilotSession(sdkSession, workspace, agentName, sessionManager); + const session = this.createCopilotSession(sdkSession, options.workspace, options.agent?.name, sessionManager); session.object.add(mcpGateway); return session; } @@ -875,12 +875,11 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS const newSessionId = generateUuid(); this._sessionsBeingCreatedViaFork.add(newSessionId); try { - const copilotUrl = this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl) || undefined; - const [sessionManager, title, { history, events: originalSessionEvents }, { sessionOptions }] = await Promise.all([ + const [sessionManager, title, { history, events: originalSessionEvents }, sessionOptions] = await Promise.all([ raceCancellationError(this.getSessionManager(), token), this.getSessionTitle(sessionId, token), requestId ? this.getChatHistoryImpl({ sessionId, workspace }, token) : Promise.resolve({ history: [], events: [] }), - this.createSessionsOptions({ workspace, mcpServers: undefined, copilotUrl, agent: undefined, sessionId: newSessionId }), + this.createSessionsOptions({ workspace, mcpServers: undefined, agent: undefined, sessionId: newSessionId }), copySessionFilesForForking(sessionId, newSessionId, workspace, this._chatSessionMetadataStore, token), ]); diff --git a/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts index a0a816acd..53eca536a 100644 --- a/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts +++ b/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -198,6 +198,52 @@ describe('CopilotCLISessionService', () => { clientName: 'vscode' })); }); + + it('passes reasoningEffort to session manager when provided', async () => { + const createSessionSpy = vi.spyOn(manager, 'createSession'); + await service.createSession({ model: 'gpt-test', reasoningEffort: 'high', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None); + + expect(createSessionSpy).toHaveBeenCalledWith(expect.objectContaining({ + model: 'gpt-test', + reasoningEffort: 'high' + })); + }); + + it('does not set reasoningEffort when not provided', async () => { + const createSessionSpy = vi.spyOn(manager, 'createSession'); + await service.createSession({ model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None); + + expect(createSessionSpy).toHaveBeenCalledWith(expect.objectContaining({ + model: 'gpt-test', + })); + const callArgs = createSessionSpy.mock.calls[0][0]; + expect(callArgs.reasoningEffort).toBeUndefined(); + }); + }); + + describe('CopilotCLISessionService.getSession', () => { + it('passes reasoningEffort to session manager when creating a new session', async () => { + const targetId = 'reasoning-get'; + manager.sessions.set(targetId, new MockCliSdkSession(targetId, new Date())); + const getSessionSpy = vi.spyOn(manager, 'getSession'); + await service.getSession({ sessionId: targetId, model: 'gpt-test', reasoningEffort: 'medium', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None); + + expect(getSessionSpy).toHaveBeenCalledWith(expect.objectContaining({ + model: 'gpt-test', + reasoningEffort: 'medium' + }), true); + }); + + it('does not set reasoningEffort when not provided', async () => { + const targetId = 'no-reasoning-get'; + manager.sessions.set(targetId, new MockCliSdkSession(targetId, new Date())); + const getSessionSpy = vi.spyOn(manager, 'getSession'); + await service.getSession({ sessionId: targetId, model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None); + + expect(getSessionSpy).toHaveBeenCalled(); + const callArgs = getSessionSpy.mock.calls[0][0]; + expect(callArgs.reasoningEffort).toBeUndefined(); + }); }); describe('CopilotCLISessionService.getSession concurrency & locking', () => { diff --git a/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts index 88d04894c..e222412ef 100644 --- a/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts +++ b/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts @@ -130,7 +130,7 @@ class MockSdkSession { setAuthInfo(info: any) { this.authInfo = info; } async getSelectedModel() { return this._selectedModel; } - async setSelectedModel(model: string) { this._selectedModel = model; } + async setSelectedModel(model: string, _reasoningEffort?: string) { this._selectedModel = model; } async getEvents() { return []; } } @@ -242,7 +242,7 @@ describe('CopilotCLISession', () => { const session = await createSession(); const stream = new MockChatResponseStream(); session.attachStream(stream); - await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Hi' }, [], 'modelB', authInfo, CancellationToken.None); + await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Hi' }, [], { model: 'modelB' }, authInfo, CancellationToken.None); expect(sdkSession._selectedModel).toBe('modelB'); }); @@ -265,7 +265,7 @@ describe('CopilotCLISession', () => { const listener = disposables.add(session.onDidChangeStatus(s => statuses.push(s))); const stream = new MockChatResponseStream(); session.attachStream(stream); - await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Status OK' }, [], 'modelA', authInfo, CancellationToken.None); + await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Status OK' }, [], { model: 'modelA' }, authInfo, CancellationToken.None); listener.dispose?.(); expect(statuses).toEqual([ChatSessionStatus.InProgress, ChatSessionStatus.Completed]); diff --git a/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 8982dc62b..7b14128c8 100644 --- a/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -43,7 +43,7 @@ import { emptyWorkspaceInfo, getWorkingDirectory, isIsolationEnabled, IWorkspace import { ICustomSessionTitleService } from '../copilotcli/common/customSessionTitleService'; import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService'; import { getCopilotCLISessionDir } from '../copilotcli/node/cliHelpers'; -import { ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK, isWelcomeView } from '../copilotcli/node/copilotCli'; +import { COPILOT_CLI_REASONING_EFFORT_PROPERTY, ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK, isWelcomeView } from '../copilotcli/node/copilotCli'; import { CopilotCLIPromptResolver } from '../copilotcli/node/copilotcliPromptResolver'; import { builtinSlashSCommands, CopilotCLICommand, copilotCLICommands, ICopilotCLISession } from '../copilotcli/node/copilotcliSession'; import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService'; @@ -185,6 +185,10 @@ function isBranchOptionFeatureEnabled(configurationService: IConfigurationServic return configurationService.getConfig(ConfigKey.Advanced.CLIBranchSupport); } +function isReasoningEffortFeatureEnabled(configurationService: IConfigurationService): boolean { + return configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled); +} + function isIsolationOptionFeatureEnabled(configurationService: IConfigurationService): boolean { return configurationService.getConfig(ConfigKey.Advanced.CLIIsolationOption); } @@ -1397,7 +1401,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { } } - private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: string | undefined; agent: SweCustomAgent | undefined; branchName: Promise }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; trusted: boolean }> { + private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; branchName: Promise }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; trusted: boolean }> { const { resource } = chatSessionContext.chatSessionItem; const sessionId = SessionIdForCLI.parse(resource); const isNewSession = this.sessionService.isNewSessionId(sessionId); @@ -1409,7 +1413,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { return { session: undefined, trusted }; } - const model = options.model; + const model = options.model?.model; const agent = options.agent; const debugTargetSessionIds = extractDebugTargetSessionIds(request.references); const mcpServerMappings = buildMcpServerMappings(request.tools); @@ -1439,15 +1443,29 @@ export class CopilotCLIChatSessionParticipant extends Disposable { return { session, trusted }; } - private async getModelId(request: vscode.ChatRequest | undefined, token: vscode.CancellationToken): Promise { + private async getModelId(request: vscode.ChatRequest | undefined, token: vscode.CancellationToken): Promise<{ model: string; reasoningEffort?: string } | undefined> { const promptFile = request ? await this.getPromptInfoFromRequest(request, token) : undefined; const model = promptFile?.header?.model ? await getModelFromPromptFile(promptFile.header.model, this.copilotCLIModels) : undefined; - if (model || token.isCancellationRequested) { - return model; + if (token.isCancellationRequested) { + return undefined; + } + if (model) { + return { model }; } // Get model from request. const preferredModelInRequest = request?.model?.id ? await this.copilotCLIModels.resolveModel(request.model.id) : undefined; - return preferredModelInRequest ?? await this.copilotCLIModels.getDefaultModel(); + if (preferredModelInRequest) { + const reasoningEffort = isReasoningEffortFeatureEnabled(this.configurationService) ? request?.modelConfiguration?.[COPILOT_CLI_REASONING_EFFORT_PROPERTY] : undefined; + return { + model: preferredModelInRequest, + reasoningEffort: typeof reasoningEffort === 'string' && reasoningEffort ? reasoningEffort : undefined + }; + } + const defaultModel = await this.copilotCLIModels.getDefaultModel(); + if (!defaultModel) { + return undefined; + } + return { model: defaultModel }; } private async handleDelegationToCloud(session: ICopilotCLISession, request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { @@ -1573,7 +1591,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { const { prompt, attachments, references } = await this.promptResolver.resolvePrompt(request, await requestPromptPromise, (otherReferences || []).concat([]), workspaceInfo, [], token); const mcpServerMappings = buildMcpServerMappings(request.tools); - const session = await this.sessionService.createSession({ workspace: workspaceInfo, agent, model, mcpServerMappings }, token); + const session = await this.sessionService.createSession({ workspace: workspaceInfo, agent, model: model?.model, mcpServerMappings }, token); const modeInstructions = this.createModeInstructions(request); this.chatSessionMetadataStore.updateRequestDetails(session.object.sessionId, [{ vscodeRequestId: request.id, agentId: agent?.name ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details')); if (summary) { diff --git a/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index c5d84c0c3..451f86f0b 100644 --- a/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -44,7 +44,7 @@ import { emptyWorkspaceInfo, getWorkingDirectory, isIsolationEnabled, IWorkspace import { ICustomSessionTitleService } from '../copilotcli/common/customSessionTitleService'; import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService'; import { getCopilotCLISessionDir } from '../copilotcli/node/cliHelpers'; -import { ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK, isWelcomeView } from '../copilotcli/node/copilotCli'; +import { COPILOT_CLI_REASONING_EFFORT_PROPERTY, ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK, isWelcomeView } from '../copilotcli/node/copilotCli'; import { CopilotCLIPromptResolver } from '../copilotcli/node/copilotcliPromptResolver'; import { builtinSlashSCommands, CopilotCLICommand, copilotCLICommands, ICopilotCLISession } from '../copilotcli/node/copilotcliSession'; import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService'; @@ -444,6 +444,10 @@ function isBranchOptionFeatureEnabled(configurationService: IConfigurationServic return configurationService.getConfig(ConfigKey.Advanced.CLIBranchSupport); } +function isReasoningEffortFeatureEnabled(configurationService: IConfigurationService): boolean { + return configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled); +} + function isIsolationOptionFeatureEnabled(configurationService: IConfigurationService): boolean { return configurationService.getConfig(ConfigKey.Advanced.CLIIsolationOption); } @@ -1671,7 +1675,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { } } - private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: string | undefined; agent: SweCustomAgent | undefined }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; trusted: boolean }> { + private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; trusted: boolean }> { const { resource } = chatSessionContext.chatSessionItem; const existingSessionId = this.sessionItemProvider.untitledSessionIdMapping.get(SessionIdForCLI.parse(resource)); const id = existingSessionId ?? SessionIdForCLI.parse(resource); @@ -1684,13 +1688,14 @@ export class CopilotCLIChatSessionParticipant extends Disposable { return { session: undefined, trusted }; } - const model = options.model; + const model = options.model?.model; + const reasoningEffort = options.model?.reasoningEffort; const agent = options.agent; const debugTargetSessionIds = extractDebugTargetSessionIds(request.references); const mcpServerMappings = buildMcpServerMappings(request.tools); const session = isNewSession ? - await this.sessionService.createSession({ model, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings }, token) : - await this.sessionService.getSession({ sessionId: id, model, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings }, token); + await this.sessionService.createSession({ model, reasoningEffort, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings }, token) : + await this.sessionService.getSession({ sessionId: id, model, reasoningEffort, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings }, token); this.sessionItemProvider.notifySessionsChange(); // TODO @DonJayamanne We need to refresh to add this new session, but we need a label. // So when creating a session we need a dummy label (or an initial prompt). @@ -1718,15 +1723,29 @@ export class CopilotCLIChatSessionParticipant extends Disposable { return { session, trusted }; } - private async getModelId(request: vscode.ChatRequest | undefined, token: vscode.CancellationToken): Promise { + private async getModelId(request: vscode.ChatRequest | undefined, token: vscode.CancellationToken): Promise<{ model: string; reasoningEffort?: string } | undefined> { const promptFile = request ? await this.getPromptInfoFromRequest(request, token) : undefined; const model = promptFile?.header?.model ? await getModelFromPromptFile(promptFile.header.model, this.copilotCLIModels) : undefined; - if (model || token.isCancellationRequested) { - return model; + if (token.isCancellationRequested) { + return undefined; + } + if (model) { + return { model }; } // Get model from request. const preferredModelInRequest = request?.model?.id ? await this.copilotCLIModels.resolveModel(request.model.id) : undefined; - return preferredModelInRequest ?? await this.copilotCLIModels.getDefaultModel(); + if (preferredModelInRequest) { + const reasoningEffort = isReasoningEffortFeatureEnabled(this.configurationService) ? request?.modelConfiguration?.[COPILOT_CLI_REASONING_EFFORT_PROPERTY] : undefined; + return { + model: preferredModelInRequest, + reasoningEffort: typeof reasoningEffort === 'string' && reasoningEffort ? reasoningEffort : undefined + }; + } + const defaultModel = await this.copilotCLIModels.getDefaultModel(); + if (!defaultModel) { + return undefined; + } + return { model: defaultModel }; } private async handleDelegationToCloud(session: ICopilotCLISession, request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { @@ -1835,7 +1854,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { const { prompt, attachments, references } = await this.promptResolver.resolvePrompt(request, await requestPromptPromise, (otherReferences || []).concat([]), workspaceInfo, [], token); const mcpServerMappings = buildMcpServerMappings(request.tools); - const session = await this.sessionService.createSession({ workspace: workspaceInfo, agent, model, mcpServerMappings }, token); + const session = await this.sessionService.createSession({ workspace: workspaceInfo, agent, model: model?.model, mcpServerMappings }, token); const modeInstructions = this.createModeInstructions(request); this.chatSessionMetadataStore.updateRequestDetails(session.object.sessionId, [{ vscodeRequestId: request.id, agentId: agent?.name ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details')); if (summary) { diff --git a/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index 9aa15e597..cd36ebe44 100644 --- a/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -243,15 +243,15 @@ function createChatContext(sessionId: string, isUntitled: boolean): vscode.ChatC } class TestCopilotCLISession extends CopilotCLISession { - public requests: Array<{ input: CopilotCLISessionInput; attachments: Attachment[]; modelId: string | undefined; authInfo: NonNullable; token: vscode.CancellationToken }> = []; + public requests: Array<{ input: CopilotCLISessionInput; attachments: Attachment[]; model: { model: string; reasoningEffort?: string } | undefined; authInfo: NonNullable; token: vscode.CancellationToken }> = []; public static nextHandleRequestResult: Promise | undefined; public static handleRequestHook: ((request: { id: string; toolInvocationToken: vscode.ChatParticipantToolToken; sessionResource?: vscode.Uri }, input: CopilotCLISessionInput) => Promise) | undefined; public static statusOverride?: vscode.ChatSessionStatus; override get status(): vscode.ChatSessionStatus | undefined { return TestCopilotCLISession.statusOverride; } - override handleRequest(request: { id: string; toolInvocationToken: vscode.ChatParticipantToolToken; sessionResource?: vscode.Uri }, input: CopilotCLISessionInput, attachments: Attachment[], modelId: string | undefined, authInfo: NonNullable, token: vscode.CancellationToken): Promise { - this.requests.push({ input, attachments, modelId, authInfo, token }); + override handleRequest(request: { id: string; toolInvocationToken: vscode.ChatParticipantToolToken; sessionResource?: vscode.Uri }, input: CopilotCLISessionInput, attachments: Attachment[], model: { model: string; reasoningEffort?: string } | undefined, authInfo: NonNullable, token: vscode.CancellationToken): Promise { + this.requests.push({ input, attachments, model, authInfo, token }); if (TestCopilotCLISession.handleRequestHook) { return TestCopilotCLISession.handleRequestHook(request, input); } @@ -447,7 +447,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { expect(cliSessions.length).toBe(1); expect(cliSessions[0].requests.length).toBe(1); - expect(cliSessions[0].requests[0]).toEqual({ input: { prompt: 'Say hi' }, attachments: [], modelId: 'base', authInfo, token }); + expect(cliSessions[0].requests[0]).toEqual({ input: { prompt: 'Say hi' }, attachments: [], model: { model: 'base' }, authInfo, token }); }); it('uses worktree workingDirectory when isolation is enabled for a new untitled session', async () => { @@ -519,7 +519,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { expect(cliSessions.length).toBe(1); expect(cliSessions[0].sessionId).toBe(sessionId); expect(cliSessions[0].requests.length).toBe(1); - expect(cliSessions[0].requests[0]).toEqual({ input: { prompt: 'Continue' }, attachments: [], modelId: 'base', authInfo, token }); + expect(cliSessions[0].requests[0]).toEqual({ input: { prompt: 'Continue' }, attachments: [], model: { model: 'base' }, authInfo, token }); expect(itemProvider.swap).not.toHaveBeenCalled(); }); diff --git a/src/platform/configuration/common/configurationService.ts b/src/platform/configuration/common/configurationService.ts index 1d4cfdaf7..861c8577b 100644 --- a/src/platform/configuration/common/configurationService.ts +++ b/src/platform/configuration/common/configurationService.ts @@ -616,6 +616,7 @@ export namespace ConfigKey { export const CLIIsolationOption = defineSetting('chat.cli.isolationOption.enabled', ConfigType.Simple, true); export const CLIAutoCommitEnabled = defineSetting('chat.cli.autoCommit.enabled', ConfigType.Simple, true); export const CLISessionController = defineSetting('chat.cli.sessionController.enabled', ConfigType.Simple, false); + export const CLIThinkingEffortEnabled = defineSetting('chat.cli.thinkingEffort.enabled', ConfigType.Simple, true); export const CLISessionControllerForSessionsApp = defineSetting('chat.cli.sessionControllerForSessionsApp.enabled', ConfigType.Simple, false); export const CLITerminalLinks = defineSetting('chat.cli.terminalLinks.enabled', ConfigType.Simple, true); export const RequestLoggerMaxEntries = defineAndMigrateSetting('chat.advanced.debug.requestLogger.maxEntries', 'chat.debug.requestLogger.maxEntries', 100); diff --git a/test/e2e/cli.stest.ts b/test/e2e/cli.stest.ts index fa9b4edd6..566172998 100644 --- a/test/e2e/cli.stest.ts +++ b/test/e2e/cli.stest.ts @@ -202,13 +202,13 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl protected override async createSessionsOptions(options: { model?: string; workingDirectory?: Uri; workspace: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; sessionId?: string; debugTargetSessionIds?: readonly string[] }) { const testOptionsProvider = this.instantiationService.invokeFunction((accessor) => accessor.get(ITestSessionOptionsProvider)); const overrideOptions = await testOptionsProvider.getOptions(); - const result = await super.createSessionsOptions({ ...options, agent: undefined }); - const mutableOptions = result.sessionOptions as SessionOptions; - mutableOptions.authInfo = overrideOptions.authInfo ?? result.sessionOptions.authInfo; - mutableOptions.copilotUrl = overrideOptions.copilotUrl ?? result.sessionOptions.copilotUrl; + const sessionOptions = await super.createSessionsOptions({ ...options, agent: undefined }); + const mutableOptions = sessionOptions as SessionOptions; + mutableOptions.authInfo = overrideOptions.authInfo ?? sessionOptions.authInfo; + mutableOptions.copilotUrl = overrideOptions.copilotUrl ?? sessionOptions.copilotUrl; mutableOptions.enableStreaming = true; mutableOptions.skipCustomInstructions = true; - return result; + return sessionOptions; } }