From 95bb9a82bec2e9e31852889e3e73920bd3d09cbe Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Tue, 8 Sep 2026 15:23:37 -0400 Subject: [PATCH 1/3] agentHost: switch Auto routing preferences with the SDK Forward Auto preferences and explicit resets through the SDK instead of pinning the launch profile. Unify Auto-tier settings across Copilot Chat and agent-host sessions, and align the picker and accessibility wording. Fixes #333809. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 61130d6b-7319-480e-92d1-b6b08949e03a --- extensions/copilot/package.json | 20 --- extensions/copilot/package.nls.json | 1 - .../common/configurationService.ts | 13 +- .../platform/endpoint/node/automodeService.ts | 2 +- .../node/test/automodeService.spec.ts | 6 +- .../agentHost/common/autoModeTiers.ts | 10 ++ .../agentHost/common/copilotCliConfig.ts | 31 ++--- .../agentHost/node/copilot/copilotAgent.ts | 62 ++------- .../node/copilot/copilotAgentSession.ts | 16 +-- .../node/copilot/copilotSessionLauncher.ts | 61 +++++---- .../agentHost/test/node/copilotAgent.test.ts | 126 +++++++++++++++--- .../test/node/copilotAgentSession.test.ts | 22 ++- .../test/node/copilotSessionLauncher.test.ts | 105 +++++++++------ .../browser/actions/chatAccessibilityHelp.ts | 2 +- ...agentHostCopilotCliSettingsContribution.ts | 11 +- .../chat/browser/chat.shared.contribution.ts | 14 +- .../input/modelPicker/modelPickerCard.ts | 2 +- .../input/modelPicker/modelPickerHover.ts | 5 +- .../chatAccessibilityHelp.test.ts | 8 +- ...HostCopilotCliSettingsContribution.test.ts | 14 +- .../modelPicker/modelPickerHover.test.ts | 8 +- 21 files changed, 319 insertions(+), 220 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 647efa0778ad64..6e7095dc2e3a8a 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4406,17 +4406,6 @@ "advanced" ] }, - "github.copilot.chat.autoModeTierOverride": { - "type": [ - "string", - "null" - ], - "default": null, - "markdownDescription": "Overrides the routing tier that the `Auto` model requests, ignoring both the tier picked in the model picker and the tier inline chat defaults to. Accepts `efficiency`, `balance`, `intelligence`, or `fast`. Used by evals.\n\n**Note**: This is an advanced debugging setting.", - "tags": [ - "advanced" - ] - }, "github.copilot.chat.anthropic.promptCaching.extendedTtl": { "type": "boolean", "default": false, @@ -5089,15 +5078,6 @@ "advanced" ] }, - "github.copilot.chat.autoMode.tiers.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%github.copilot.config.chat.autoMode.tiers.enabled%", - "tags": [ - "advanced", - "onExp" - ] - }, "github.copilot.chat.agent.modelDetails.enabled": { "type": "boolean", "default": true, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 2c49c81c487814..01a9c24797be2b 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -424,7 +424,6 @@ "github.copilot.config.cli.forkSessions.enabled": "Enable forking sessions in Copilot CLI.", "github.copilot.config.cli.planExitMode.enabled": "Enable Plan Mode exit handling in Copilot CLI.", "github.copilot.config.cli.autoModel.enabled": "Enable the Auto model option in Copilot CLI, which automatically selects the best model for each request. Requires VS Code reload.", - "github.copilot.config.chat.autoMode.tiers.enabled": "Choose a routing tier for the Auto model, biasing model selection toward cost, capability, or speed. When disabled, the service picks the routing profile.", "github.copilot.config.chat.agent.modelDetails.enabled": "Show model details (model name and request multiplier) on Copilot CLI agent chat responses. Requires VS Code reload to update already loaded sessions.", "github.copilot.config.cli.planCommand.enabled": "Enable the /plan command in Copilot CLI to create implementation plans before coding.", "github.copilot.config.cli.lazyLoadSessionItem.enabled": "Enable lazy loading of session items in Copilot CLI. Requires VS Code reload.", diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 4b578b629ab3da..776b05e7e47691 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -746,11 +746,8 @@ export namespace ConfigKey { export const OmitBaseAgentInstructions = defineAndMigrateSetting('chat.advanced.omitBaseAgentInstructions', 'chat.omitBaseAgentInstructions', false); export const CLIPlanExitModeEnabled = defineSetting('chat.cli.planExitMode.enabled', ConfigType.Simple, true); export const CLIAutoModelEnabled = defineSetting('chat.cli.autoModel.enabled', ConfigType.Simple, true); - /** - * Offer routing tiers on the Auto model. Off by default: while disabled - * no tier is sent and the server picks its own routing profile. - */ - export const AutoModeTiersEnabled = defineSetting('chat.autoMode.tiers.enabled', ConfigType.ExperimentBased, false, undefined, undefined, { experimentName: 'copilotchat.autoModeTiersEnabled' }); + /** The workbench owns the experiment default so all Auto pickers read the same effective setting. */ + export const AutoModeTiersEnabled = defineSetting('chat.autoMode.tiers.enabled', ConfigType.Simple, false); export const CLIModelDetailsEnabled = defineSetting('chat.agent.modelDetails.enabled', ConfigType.Simple, true); export const CLIPlanCommandEnabled = defineSetting('chat.cli.planCommand.enabled', ConfigType.Simple, true); export const CLIChatLazyLoadSessionItem = defineSetting('chat.cli.lazyLoadSessionItem.enabled', ConfigType.Simple, true); @@ -871,11 +868,7 @@ export namespace ConfigKey { export const ReasoningEffortOverride = defineSetting('chat.reasoningEffortOverride', ConfigType.Simple, null); export const ChatCompletionsTokenParameter = defineSetting('chat.chatCompletionsTokenParameter', ConfigType.ExperimentBased, 'max_tokens', vEnum('max_completion_tokens', 'max_tokens')); - /** - * Internal: override the routing tier sent to `POST /auto`, ignoring both the - * model picker and the tier inline chat defaults to. Unlike the picker this - * accepts `fast`, so evals can exercise every profile. - */ + /** Override Auto's "Optimize for" preference in both harnesses; the extension additionally accepts `fast`. */ export const AutoModeTierOverride = defineSetting('chat.autoModeTierOverride', ConfigType.Simple, null); /** diff --git a/extensions/copilot/src/platform/endpoint/node/automodeService.ts b/extensions/copilot/src/platform/endpoint/node/automodeService.ts index 77ec550083470d..8ca557cb2c7a6a 100644 --- a/extensions/copilot/src/platform/endpoint/node/automodeService.ts +++ b/extensions/copilot/src/platform/endpoint/node/automodeService.ts @@ -364,7 +364,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { } areAutoModeTiersSupported(): boolean { - return this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.AutoModeTiersEnabled, this._expService); + return this._configurationService.getConfig(ConfigKey.Advanced.AutoModeTiersEnabled); } /** diff --git a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts index e43a3a6003cb9b..cd33dfacf5bb3d 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts @@ -747,7 +747,8 @@ describe('AutomodeService', () => { expect(autoRequestBodies()).toEqual([{ prompt: 'panel turn', tier: 'intelligence' }]); }); - it('announces tier support when the setting changes', async () => { + it('announces tier support from the effective setting, not a separate experiment lookup', async () => { + vi.spyOn(mockExpService, 'getTreatmentVariable').mockReturnValue(true); automodeService = createService(); expect(automodeService.areAutoModeTiersSupported()).toBe(false); @@ -756,9 +757,10 @@ describe('AutomodeService', () => { await configurationService.setConfig(ConfigKey.Advanced.AutoModeTiersEnabled, true); // An unrelated change must not re-announce. await configurationService.setConfig(ConfigKey.Advanced.AutoModeTierOverride, 'intelligence'); + await configurationService.setConfig(ConfigKey.Advanced.AutoModeTiersEnabled, false); listener.dispose(); - expect({ announced, supported: automodeService.areAutoModeTiersSupported() }).toEqual({ announced: 1, supported: true }); + expect({ announced, supported: automodeService.areAutoModeTiersSupported() }).toEqual({ announced: 2, supported: false }); }); it('does not reuse a cached endpoint from a different tier when /auto fails', async () => { diff --git a/src/vs/platform/agentHost/common/autoModeTiers.ts b/src/vs/platform/agentHost/common/autoModeTiers.ts index cc94cb00b3e577..e344caf0188256 100644 --- a/src/vs/platform/agentHost/common/autoModeTiers.ts +++ b/src/vs/platform/agentHost/common/autoModeTiers.ts @@ -21,6 +21,16 @@ export function isAutoModeTier(value: unknown): value is AutoModeTier { return autoModeTiers.some(tier => tier === value); } +/** Maps retired picker and override values to the current runtime names, matching the Copilot extension. */ +export function normalizeAutoModeTier(value: unknown): unknown { + switch (value) { + case 'eco': return 'efficiency'; + case 'balanced': return 'balance'; + case 'max': return 'intelligence'; + default: return value; + } +} + /** * Localized picker label for a routing profile, capitalizing an unrecognized value so a new profile * never surfaces raw. Wording matches the extension's `getAutoModeTierLabel`, which cannot be imported here. diff --git a/src/vs/platform/agentHost/common/copilotCliConfig.ts b/src/vs/platform/agentHost/common/copilotCliConfig.ts index ca3e3489bad967..f436ba71643266 100644 --- a/src/vs/platform/agentHost/common/copilotCliConfig.ts +++ b/src/vs/platform/agentHost/common/copilotCliConfig.ts @@ -34,11 +34,10 @@ export const enum CopilotCliConfigKey { ReasoningSummary = 'reasoningSummary', /** Let the Auto router score prior turns instead of the latest message alone. Off by default. */ MultiTurnContextRouting = 'multiTurnContextRouting', - /** - * Offer the Auto model's routing-profile ("Optimize for") picker. Off by default. Separate from - * the extension's gate, which tracks CAPI readiness rather than the bundled runtime's support. - */ + /** Offer the Auto model's "Optimize for" picker. Shares the Copilot extension's setting and experiment. */ AutoModeTiers = 'autoModeTiers', + /** Override Auto's "Optimize for" preference, even when the picker is disabled. */ + AutoModeTierOverride = 'autoModeTierOverride', /** Tell the model to keep subagents on their default model unless the user asks otherwise. Off by default. */ SubagentModelGuidance = 'subagentModelGuidance', /** Per-model capability overrides (family aliases) keyed by model id. */ @@ -47,10 +46,7 @@ export const enum CopilotCliConfigKey { export const CopilotCliVSCodeAssignmentContextKey = 'copilotCliVSCodeAssignmentContext'; -// VS Code `chat.agentHost.*` / `chat.copilot.*` setting IDs that feed the root-config -// keys above, kept beside the keys they forward to. Registered in `chat.shared.contribution.ts` -// and forwarded into the host's root config by `AgentHostCopilotCliSettingsContribution` -// (and, for the terminal-tool toggle, `AgentHostTerminalContribution`). +// Client setting IDs forwarded into the matching provider-owned root-config keys. export const AgentHostCustomTerminalToolEnabledSettingId = 'chat.agentHost.customTerminalTool.enabled'; @@ -71,12 +67,11 @@ export const AgentHostReasoningSummaryEnabledSettingId = 'chat.agentHost.copilot export const AgentHostMultiTurnContextRoutingEnabledSettingId = 'chat.agentHost.copilot.multiTurnContextRouting.enabled'; -export const AgentHostAutoModeTiersEnabledSettingId = 'chat.agentHost.copilot.autoModeTiers.enabled'; +export const CopilotAutoModeTiersEnabledSettingId = 'github.copilot.chat.autoMode.tiers.enabled'; -/** - * ExP treatment gating the Auto routing-profile picker. The Copilot extension gates its own picker - * on this same name, so one assignment turns tiers on for both harnesses. - */ +export const CopilotAutoModeTierOverrideSettingId = 'github.copilot.chat.autoModeTierOverride'; + +/** Applied to the shared setting's default by the workbench configuration service. */ export const AutoModeTiersExperimentName = 'copilotchat.autoModeTiersEnabled'; export const CopilotSubagentModelGuidanceEnabledSettingId = 'chat.copilot.subagentModelGuidance.enabled'; @@ -208,10 +203,16 @@ export const copilotCliConfigSchema = createSchema({ }), [CopilotCliConfigKey.AutoModeTiers]: schemaProperty({ type: 'boolean', - title: localize('agentHost.config.autoModeTiers.title', "Auto Routing Profiles"), - description: localize('agentHost.config.autoModeTiers.description', "When enabled, the Auto model offers an \"Optimize for\" picker that biases routing toward efficiency, balance, or intelligence. The profile is chosen before a session starts and applies for its lifetime."), + title: localize('agentHost.config.autoModeTiers.title', "Auto Optimize for"), + description: localize('agentHost.config.autoModeTiers.description', "When enabled, the Auto model offers an \"Optimize for\" picker with Efficiency, Balance, and Intelligence options. You can change the preference during a session. When disabled, the service chooses how to route unless an override is configured."), default: false, }), + [CopilotCliConfigKey.AutoModeTierOverride]: schemaProperty({ + type: 'string', + title: localize('agentHost.config.autoModeTierOverride.title', "Auto Optimize for Override"), + description: localize('agentHost.config.autoModeTierOverride.description', "Overrides Auto's \"Optimize for\" preference, even when the picker is disabled. Accepts efficiency, balance, or intelligence. Applied when a session is created or resumed and when its model changes. Empty or unsupported values use the picker or service defaults."), + default: '', + }), [CopilotCliConfigKey.SubagentModelGuidance]: schemaProperty({ type: 'boolean', title: localize('agentHost.config.subagentModelGuidance.title', "Subagent Model Guidance"), diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 56289130dd4b03..5a49e8c8cdc750 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -45,7 +45,7 @@ import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPlu import { decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentChatBackings.js'; import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent, type IAgentTurnDiagnosticSnapshot } from '../../common/agent.js'; import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js'; -import { autoModeTiers, defaultAutoModeTier, getAutoModeTierDescription, getAutoModeTierLabel, type AutoModeTier } from '../../common/autoModeTiers.js'; +import { autoModeTiers, defaultAutoModeTier, getAutoModeTierDescription, getAutoModeTierLabel } from '../../common/autoModeTiers.js'; import { isAutoModel } from './modelIdentifiers.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; @@ -3934,13 +3934,6 @@ export class CopilotAgent extends Disposable implements IAgent { try { const resolvedAgent = provisional.isEphemeral ? undefined : await this._resolveAgentWhenMaterializing(provisional, snapshot, workingDirectory); agent = resolvedAgent?.agent; - // Resolve the profile once and freeze it on the plan. The gate can flip while the session - // is provisional, or during the async launch below. - const autoTier = resolveCopilotAutoTier(provisional.model, this._configurationService, this._logService, sdkSessionId); - const model = provisional.model - ? this._withEffectiveAutoTier(provisional.model, autoTier, sdkSessionId, 'Auto routing profiles are unavailable') - : undefined; - provisional.model = model; const launchPlan: CopilotSessionLaunchPlan = { kind: 'create', client, @@ -3958,7 +3951,6 @@ export class CopilotAgent extends Disposable implements IAgent { model: provisional.model, longContextWindow: this._longContextWindowFor(provisional.model?.id), freeLongContext: this._isFreeLongContext(provisional.model?.id), - autoTier, workspaceless: provisional.workspaceless, }; const chatChannelUri = this._findBoundSessionChatUri(sdkSessionId) ?? URI.parse(buildDefaultChatUri(sessionUri)); @@ -4458,8 +4450,6 @@ export class CopilotAgent extends Disposable implements IAgent { let launchPlan: CopilotSessionLaunchPlan; let sdkSessionId: string; let inheritedTurnId: string | undefined; - // Frozen before the async launch so the profile sent matches the one persisted below. - const forkAutoTier = resolveCopilotAutoTier(model, this._configurationService, this._logService, chatSdkId); let sourceEntry: CopilotAgentSession | undefined; if (fork) { sourceEntry = await this._ensureResolvedChatSession(this._resolveChatContext(fork.source, { configurationResource: forkSourceScope!, resource: this._resolveChatStorageScope(fork.source) })); @@ -4480,7 +4470,7 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, - fallback: { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id), autoTier: forkAutoTier }, + fallback: { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id) }, }; } else { sdkSessionId = chatSdkId; @@ -4498,7 +4488,6 @@ export class CopilotAgent extends Disposable implements IAgent { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id), - autoTier: forkAutoTier, }; } @@ -4940,7 +4929,7 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, - fallback: { model: info.model, longContextWindow: this._longContextWindowFor(info.model?.id), freeLongContext: this._isFreeLongContext(info.model?.id), autoTier: resolveCopilotAutoTier(info.model, this._configurationService, this._logService, context.sdkSessionId ?? context.configurationId) }, + fallback: { model: info.model, longContextWindow: this._longContextWindowFor(info.model?.id), freeLongContext: this._isFreeLongContext(info.model?.id) }, }; agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, { sessionUri: configurationResource, chatChannelUri: chat, resource: context.resource }); const initializingSession = agentSession; @@ -5037,60 +5026,30 @@ export class CopilotAgent extends Disposable implements IAgent { // A `family` alias routes the host's prompt and tool profile only. The // selected model's reasoning-effort override is resolved separately. const provisional = this._provisionalSessions.get(current.configurationId); - // The selection to record, which can differ from the request: the runtime fixes a - // materialized session's routing profile. - let recorded = model; if (provisional) { provisional.model = model; } else { const entry = current.target ?? await this._ensureResolvedChatSession(current); - await entry?.setModel(model.id, resolveCopilotReasoningEffort(model, this._configurationService, this._logService, current.configurationId), getCopilotContextTier(model, longContextWindow, freeLongContext)); - if (entry) { - recorded = this._pinLaunchAutoTier(model, entry, current.configurationId); - } + // Clear stale SDK preferences when the picker is disabled or an override is removed. + const autoTier = isAutoModel(model.id) + ? resolveCopilotAutoTier(model, this._configurationService, this._logService, current.configurationId) ?? null + : undefined; + await entry?.setModel(model.id, resolveCopilotReasoningEffort(model, this._configurationService, this._logService, current.configurationId), getCopilotContextTier(model, longContextWindow, freeLongContext), autoTier); // Keep the session-scope metadata in step for resumes that fall back // to it; chat leaves persist through their backing instead. if (current.resource.toString() === current.configurationResource.toString()) { - await this._storeSessionMetadata(current.resource, recorded, undefined, undefined, undefined, undefined); + await this._storeSessionMetadata(current.resource, model, undefined, undefined, undefined, undefined); } } const backing = this._chatBackings.get(current.chatKey); if (backing) { - const updated: IPersistedChat = { ...backing, model: recorded }; + const updated: IPersistedChat = { ...backing, model }; this._chatBackings.set(current.chatKey, updated); this._onDidChangeChatData.fire({ chat, providerData: encodeProviderData(updated) }); } }); } - /** - * Rewrites a model selection so its Auto routing profile matches the one the runtime is actually - * using. Recording anything else would leave the picker and resume metadata out of step. - */ - private _withEffectiveAutoTier(model: ModelSelection, effective: AutoModeTier | undefined, sessionId: string, reason: string): ModelSelection { - // Only the Auto model routes per turn, so any other selection carries no profile to correct. - if (!isAutoModel(model.id)) { - return model; - } - const requested = model.config?.[AutoTierConfigKey]; - if (requested === effective) { - return model; - } - this._logService.info(`[Copilot:${sessionId}] ${reason}; recording '${effective ?? 'the service default'}' instead of '${requested}'`); - const config = { ...model.config }; - if (effective === undefined) { - delete config[AutoTierConfigKey]; - } else { - config[AutoTierConfigKey] = effective; - } - return Object.keys(config).length > 0 ? { ...model, config } : { id: model.id }; - } - - /** The selection to record for a session that launched with `session`'s fixed routing profile. */ - private _pinLaunchAutoTier(model: ModelSelection, session: CopilotAgentSession, sessionId: string): ModelSelection { - return this._withEffectiveAutoTier(model, session.launchAutoTier, sessionId, 'Auto routing profile is fixed for this session'); - } - private async _changeAgent(chat: URI, agent: AgentSelection | undefined, operationContext: URI | IAgentChatContext): Promise { try { await this._changeAgentOnce(chat, agent, operationContext); @@ -5532,7 +5491,6 @@ export class CopilotAgent extends Disposable implements IAgent { model: storedMetadata.model, longContextWindow: this._longContextWindowFor(storedMetadata.model?.id), freeLongContext: this._isFreeLongContext(storedMetadata.model?.id), - autoTier: resolveCopilotAutoTier(storedMetadata.model, this._configurationService, this._logService, sessionId), }, }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index e77c15b2ea4545..e1658970250b23 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -3317,18 +3317,14 @@ export class CopilotAgentSession extends Disposable { await this._disposeShellInitScript(); } - /** - * The Auto routing profile this session launched with, which the runtime fixes for the session's - * lifetime. Read from the frozen plan so a later gate flip cannot change what it reports. - */ - get launchAutoTier(): AutoModeTier | undefined { - return this._launchPlan.kind === 'create' ? this._launchPlan.autoTier : this._launchPlan.fallback.autoTier; - } - - async setModel(model: string, reasoningEffort?: SessionConfig['reasoningEffort'], contextTier?: SessionConfig['contextTier']): Promise { + async setModel(model: string, reasoningEffort?: SessionConfig['reasoningEffort'], contextTier?: SessionConfig['contextTier'], autoTier?: AutoModeTier | null): Promise { this._logService.info(`[Copilot:${this.sessionId}] Changing model to: ${model}`); + await this._awaitControlPlaneRpc('session.setModel', this._wrapper.session.setModel(model, { + reasoningEffort, + contextTier, + ...(autoTier !== undefined ? { autoTier } : {}), + })); this._lastSeenModelId = model; - await this._awaitControlPlaneRpc('session.setModel', this._wrapper.session.setModel(model, { reasoningEffort, contextTier })); } /** diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 3754966ee00b79..6b6b1edd613bc1 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -18,7 +18,7 @@ import { CopilotCliConfigKey, copilotCliConfigSchema, normalizeModelFamilyAlias, import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { reasoningEffortLevels, type ReasoningEffortLevel } from '../../common/reasoningEffort.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; -import { isAutoModeTier, type AutoModeTier } from '../../common/autoModeTiers.js'; +import { autoModeTiers, isAutoModeTier, normalizeAutoModeTier, type AutoModeTier } from '../../common/autoModeTiers.js'; import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; import type { ModelSelection, ToolDefinition } from '../../common/state/protocol/state.js'; import { RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; @@ -101,12 +101,6 @@ export function toSdkReasoningEffort(effort: AgentHostReasoningEffort | undefine const ContextTiers = ['default', 'long_context'] as const; const AGENT_HOST_COPILOT_CLIENT_NAME = 'vscode-agent-host'; -/** - * The SDK's CAPI options widened with `autoTier`. The published SDK types still declare only - * `enableWebSocketResponses`; drop the widening once they catch up. - */ -type CapiSessionOptionsWithAutoTier = NonNullable & { autoTier?: AutoModeTier }; - type UserInputHandler = NonNullable; type UserInputRequest = Parameters[0]; type UserInputInvocation = Parameters[1]; @@ -274,11 +268,6 @@ export interface ICopilotCreateSessionLaunchPlan extends ICopilotSessionLaunchBa readonly model: ModelSelection | undefined; readonly longContextWindow?: number; readonly freeLongContext?: boolean; - /** - * The Auto routing profile to send, already resolved against the gate. Frozen here so the profile - * the runtime receives always matches the one the caller persists. - */ - readonly autoTier?: AutoModeTier; } export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBase { @@ -288,7 +277,6 @@ export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBa readonly model: ModelSelection | undefined; readonly longContextWindow?: number; readonly freeLongContext?: boolean; - readonly autoTier?: AutoModeTier; }; } @@ -466,31 +454,49 @@ export function getCopilotContextTier(model: ModelSelection | undefined, longCon } /** - * The Auto routing profile ("Optimize for") a session should launch with. Only the Auto model routes - * per turn, so a profile left on another model by an earlier selection is ignored. + * The Auto routing preference ("Optimize for"). Profiles left on concrete models are ignored. */ export function getCopilotAutoTier(model: ModelSelection | undefined): AutoModeTier | undefined { if (!isAutoModel(model?.id)) { return undefined; } - const tier = model?.config?.[AutoTierConfigKey]; + const tier = normalizeAutoModeTier(model?.config?.[AutoTierConfigKey]); return isAutoModeTier(tier) ? tier : undefined; } -/** - * {@link getCopilotAutoTier} behind the `autoModeTiers` gate. Re-read here rather than trusted from - * the picker schema, since the runtime rejects unknown `capi` fields and would fail the session. - */ +/** Resolves the shared Auto override independently of the picker gate, leaving concrete models unchanged. */ +function resolveConfiguredAutoTierOverride(model: ModelSelection | undefined, configurationService: Pick, logService: ILogService, sessionId: string): AutoModeTier | undefined { + if (model && !isAutoModel(model.id)) { + return undefined; + } + const override = configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.AutoModeTierOverride); + if (!override) { + return undefined; + } + const tier = normalizeAutoModeTier(override); + if (!isAutoModeTier(tier)) { + logService.warn(`[Copilot:${sessionId}] Ignoring unsupported Auto "Optimize for" override '${override}'; expected one of [${autoModeTiers.join(', ')}]`); + return undefined; + } + logService.info(`[Copilot:${sessionId}] Applying Auto "Optimize for" override '${tier}'`); + return tier; +} + +/** Resolves the shared override first, then the picker preference while "Optimize for" is enabled. */ export function resolveCopilotAutoTier(model: ModelSelection | undefined, configurationService: Pick, logService: ILogService, sessionId: string): AutoModeTier | undefined { + const override = resolveConfiguredAutoTierOverride(model, configurationService, logService, sessionId); + if (override !== undefined) { + return override; + } const tier = getCopilotAutoTier(model); if (tier === undefined) { return undefined; } if (configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.AutoModeTiers) !== true) { - logService.trace(`[Copilot:${sessionId}] Auto routing profiles are disabled; ignoring '${tier}'`); + logService.trace(`[Copilot:${sessionId}] Auto "Optimize for" is disabled; ignoring '${tier}'`); return undefined; } - logService.info(`[Copilot:${sessionId}] Launching Auto session with the '${tier}' routing profile`); + logService.info(`[Copilot:${sessionId}] Using Auto "Optimize for" preference '${tier}'`); return tier; } @@ -498,12 +504,11 @@ export function resolveCopilotAutoTier(model: ModelSelection | undefined, config * The CAPI session options for a routing profile. Omitting `capi` entirely, rather than sending an * empty profile, is what leaves the runtime on its default routing. */ -export function toSdkCapiSessionOptions(autoTier: AutoModeTier | undefined): Pick { +function toSdkCapiSessionOptions(autoTier: AutoModeTier | undefined): Pick { if (autoTier === undefined) { return {}; } - const capi: CapiSessionOptionsWithAutoTier = { autoTier }; - return { capi }; + return { capi: { autoTier } }; } /** @@ -655,7 +660,6 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { model: fallbackPlan.fallback.model, longContextWindow: fallbackPlan.fallback.longContextWindow, freeLongContext: fallbackPlan.fallback.freeLongContext, - autoTier: fallbackPlan.fallback.autoTier, }, fallbackConfig, sandboxConfig); this._sessionOpenTelemetry.sdkResumeFallbackCreated(session); this._logService.info(`[Copilot:${plan.sessionId}] Fallback createSession succeeded`); @@ -683,9 +687,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { model: plan.model?.id, reasoningEffort: resolveCopilotReasoningEffort(plan.model, this._configurationService, this._logService, plan.sessionId), contextTier: getCopilotContextTier(plan.model, plan.longContextWindow, plan.freeLongContext), - // Create-time only. Taken from the plan rather than resolved again, so the profile sent - // always matches the one the caller persists. - ...toSdkCapiSessionOptions(plan.autoTier), + ...toSdkCapiSessionOptions(resolveCopilotAutoTier(plan.model, this._configurationService, this._logService, plan.sessionId)), ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), workingDirectory: plan.workingDirectory?.fsPath, })); @@ -985,6 +987,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { // while a resumed session keeps the effort the runtime journaled unless // an override is configured. ...(plan.kind === 'resume' ? { reasoningEffort: resolveConfiguredReasoningEffortOverride(model, this._configurationService, this._logService, plan.sessionId) } : {}), + ...(plan.kind === 'resume' ? toSdkCapiSessionOptions(resolveConfiguredAutoTierOverride(model, this._configurationService, this._logService, plan.sessionId)) : {}), modelCapabilities, enableMcpApps: true, githubMcpToolConfig: { disableFormDeferral: true }, diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 51129df75a32bf..a50f93399b17c6 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { CopilotClient, CopilotClientOptions, CopilotSession, GitHubTelemetryNotification, PermissionMode, PermissionRequest, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; +import type { CopilotClient, CopilotClientOptions, CopilotSession, GitHubTelemetryNotification, PermissionMode, PermissionRequest, SessionConfig, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; import type Anthropic from '@anthropic-ai/sdk'; import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; @@ -764,6 +764,8 @@ interface ICredentialUpdateSession { } class MockCopilotSession { + readonly setModelCalls: Parameters[] = []; + setModelError: Error | undefined; readonly workingDirectoryCalls: string[] = []; readonly workingDirectoryErrors: Array = []; readonly workingDirectoryResults: string[] = []; @@ -830,7 +832,12 @@ class MockCopilotSession { async send(): Promise { return ''; } async abort(): Promise { } - async setModel(): Promise { } + async setModel(...args: Parameters): Promise { + this.setModelCalls.push(args); + if (this.setModelError) { + throw this.setModelError; + } + } async getEvents(): Promise[]> { return []; } async disconnect(): Promise { } } @@ -10816,7 +10823,7 @@ suite('CopilotAgent', () => { readonly remapCalls: ReadonlyMap[]; readonly sends: { prompt: string; turnId: string | undefined; mode: unknown; senderClientId: string | undefined }[]; readonly resets: { turnId: string; senderClientId: string | undefined }[]; - readonly modelCalls: { id: string; effort: string | undefined; tier?: string | undefined }[]; + readonly modelCalls: { id: string; effort: string | undefined; tier?: string | undefined; autoTier?: string | null }[]; readonly agentCalls: (string | undefined)[]; aborted: number; discardedTurns: number; @@ -10857,7 +10864,9 @@ suite('CopilotAgent', () => { rec.sends.push({ prompt, turnId, mode, senderClientId }); }, resetTurnState(turnId: string, senderClientId: string | undefined): void { rec.resets.push({ turnId, senderClientId }); }, - async setModel(id: string, reasoningEffort?: string, contextTier?: string): Promise { rec.modelCalls.push({ id, effort: reasoningEffort, tier: contextTier }); }, + async setModel(id: string, reasoningEffort?: string, contextTier?: string, autoTier?: string | null): Promise { + rec.modelCalls.push({ id, effort: reasoningEffort, tier: contextTier, ...(autoTier !== undefined ? { autoTier } : {}) }); + }, async setAgent(name: string | undefined): Promise { rec.agentCalls.push(name); }, async abort(): Promise { rec.aborted++; }, discardActiveTurn(): void { rec.discardedTurns++; }, @@ -11935,10 +11944,11 @@ suite('CopilotAgent', () => { } }); - test('changeModel keeps the Auto routing profile the session launched with', async () => { + test('changeModel forwards and persists Auto routing preferences and resets', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const client = new TestCopilotClient([], [{ id: 'auto', name: 'Auto' }]); - client.createSession = async () => new MockCopilotSession() as unknown as CopilotSession; + const sdkSession = new MockCopilotSession(); + client.createSession = async () => sdkSession as unknown as CopilotSession; const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, @@ -11956,21 +11966,104 @@ suite('CopilotAgent', () => { }); await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); - // The runtime fixed the profile at creation, so recording this change would leave a - // resumed session routing with 'intelligence' while its metadata claims 'efficiency'. - await agent.chats.changeModel(chat, { id: 'auto', config: { tier: 'efficiency' } }, exactChatContext(result.session, chat, result.session)); + const selections: ModelSelection[] = [ + { id: 'auto', config: { tier: 'efficiency' } }, + { id: 'auto', config: { tier: 'balance' } }, + { id: 'auto' }, + ]; + const storedModels: (string | undefined)[] = []; + const database = disposables.add(sessionDataService.openDatabase(session)); + for (const model of selections) { + await agent.chats.changeModel(chat, model, exactChatContext(result.session, chat, result.session)); + storedModels.push(await database.object.getMetadata('copilot.model')); + } - const stored = await sessionDataService.openDatabase(session).object.getMetadata('copilot.model'); - assert.deepStrictEqual(JSON.parse(stored ?? 'null'), { id: 'auto', config: { tier: 'intelligence' } }); + sdkSession.setModelError = new Error('Auto preference rejected'); + await assert.rejects( + agent.chats.changeModel(chat, { id: 'auto', config: { tier: 'intelligence' } }, exactChatContext(result.session, chat, result.session)), + /Auto preference rejected/, + ); + + assert.deepStrictEqual({ + modelCalls: sdkSession.setModelCalls, + storedModels, + storedAfterRejection: await database.object.getMetadata('copilot.model'), + }, { + modelCalls: [ + ['auto', { reasoningEffort: undefined, contextTier: undefined, autoTier: 'efficiency' }], + ['auto', { reasoningEffort: undefined, contextTier: undefined, autoTier: 'balance' }], + ['auto', { reasoningEffort: undefined, contextTier: undefined, autoTier: null }], + ['auto', { reasoningEffort: undefined, contextTier: undefined, autoTier: 'intelligence' }], + ], + storedModels: selections.map(model => JSON.stringify(model)), + storedAfterRejection: JSON.stringify({ id: 'auto' }), + }); } finally { await disposeAgent(agent); } }); - test('drops a provisional Auto routing profile when the gate turns off before the first send', async () => { + test('changeModel applies Auto preferences, overrides, and resets only to the targeted chat', async () => { + const { agent, configurationService } = createTestAgentContext(disposables, { + rootConfig: { [CopilotCliConfigKey.AutoModeTiers]: true }, + }); + try { + const session = AgentSession.uri('copilotcli', 'auto-tier-peer'); + const chatA = URI.parse(buildChatUri(session, 'peer-a')); + const chatB = URI.parse(buildChatUri(session, 'peer-b')); + const a = makeFakeChatSession(session, 'sdk-a'); + const b = makeFakeChatSession(session, 'sdk-b'); + setPeerChatStub(agent, chatA, a.fake); + setPeerChatStub(agent, chatB, b.fake); + const changes: string[] = []; + disposables.add(agent.onDidChangeChatData(e => changes.push(e.providerData))); + const selections: ModelSelection[] = [ + { id: 'auto', config: { tier: 'intelligence' } }, + { id: 'auto', config: { tier: 'efficiency' } }, + { id: 'auto' }, + { id: 'gpt-5', config: { tier: 'balance' } }, + { id: 'auto' }, + ]; + + await agent.chats.changeModel(chatA, selections[0], exactChatContext(session, chatA)); + configurationService.updateRootConfig({ [CopilotCliConfigKey.AutoModeTiers]: false, [CopilotCliConfigKey.AutoModeTierOverride]: 'balance' }); + await agent.chats.changeModel(chatA, selections[1], exactChatContext(session, chatA)); + configurationService.updateRootConfig({ [CopilotCliConfigKey.AutoModeTierOverride]: '' }); + await agent.chats.changeModel(chatA, selections[2], exactChatContext(session, chatA)); + configurationService.updateRootConfig({ [CopilotCliConfigKey.AutoModeTiers]: true }); + await agent.chats.changeModel(chatA, selections[3], exactChatContext(session, chatA)); + await agent.chats.changeModel(chatA, selections[4], exactChatContext(session, chatA)); + + assert.deepStrictEqual({ + aModels: a.rec.modelCalls, + bModels: b.rec.modelCalls, + changes, + persisted: chatBackings(agent).get(chatA.toString())?.model, + }, { + aModels: [ + { id: 'auto', effort: undefined, tier: undefined, autoTier: 'intelligence' }, + { id: 'auto', effort: undefined, tier: undefined, autoTier: 'balance' }, + { id: 'auto', effort: undefined, tier: undefined, autoTier: null }, + { id: 'gpt-5', effort: undefined, tier: undefined }, + { id: 'auto', effort: undefined, tier: undefined, autoTier: null }, + ], + bModels: [], + changes: selections.map(model => JSON.stringify({ sdkSessionId: 'sdk-a', model })), + persisted: { id: 'auto' }, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('does not apply a provisional Auto routing preference when the gate turns off before the first send', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const client = new TestCopilotClient([], [{ id: 'auto', name: 'Auto' }]); - client.createSession = async () => new MockCopilotSession() as unknown as CopilotSession; + const capiCalls: SessionConfig['capi'][] = []; + client.createSession = async config => { + capiCalls.push(config.capi); + return new MockCopilotSession() as unknown as CopilotSession; + }; const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, @@ -11987,13 +12080,14 @@ suite('CopilotAgent', () => { model: { id: 'auto', config: { tier: 'intelligence' } }, }); - // Still provisional, so the launcher has not run. With the gate off it omits - // `capi.autoTier`, so persisting the selection would claim a profile never sent. configurationService.updateRootConfig({ [CopilotCliConfigKey.AutoModeTiers]: false }); await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); const stored = await sessionDataService.openDatabase(session).object.getMetadata('copilot.model'); - assert.deepStrictEqual(JSON.parse(stored ?? 'null'), { id: 'auto' }); + assert.deepStrictEqual({ model: JSON.parse(stored ?? 'null'), capiCalls }, { + model: { id: 'auto', config: { tier: 'intelligence' } }, + capiCalls: [undefined], + }); } finally { await disposeAgent(agent); } diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 4cc306690c63a7..5549ff833d8f84 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -121,6 +121,7 @@ class MockCopilotSession { abortCalls = 0; abortGate: Promise | undefined; modelGate: Promise | undefined; + readonly setModelCalls: Parameters[] = []; agentSelectGate: Promise | undefined; agentDeselectGate: Promise | undefined; readonly compactCalls: unknown[] = []; @@ -281,7 +282,10 @@ class MockCopilotSession { this.abortCalls++; await this.abortGate; } - async setModel() { await this.modelGate; } + async setModel(...args: Parameters) { + this.setModelCalls.push(args); + await this.modelGate; + } async getEvents(): Promise { return this.messages; } async disconnect() { this.disconnectCalls++; @@ -1224,6 +1228,22 @@ suite('CopilotAgentSession', () => { }); }); + test('forwards Auto routing preferences and explicit resets with the model configuration', async () => { + const { session, mockSession } = await createAgentSession(disposables); + + await session.setModel('auto', 'high', 'long_context', 'intelligence'); + await session.setModel('auto', undefined, undefined, null); + await session.setModel('gpt-5', 'low', 'default'); + await session.setModel('auto'); + + assert.deepStrictEqual(mockSession.setModelCalls, [ + ['auto', { reasoningEffort: 'high', contextTier: 'long_context', autoTier: 'intelligence' }], + ['auto', { reasoningEffort: undefined, contextTier: undefined, autoTier: null }], + ['gpt-5', { reasoningEffort: 'low', contextTier: 'default' }], + ['auto', { reasoningEffort: undefined, contextTier: undefined }], + ]); + }); + test('times out non-settling SDK control-plane RPCs', async () => { const neverSettles = new Promise(() => { }); const { session, mockSession } = await createAgentSession(disposables, { controlPlaneRpcTimeoutMs: 1 }); diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index 1d5cb490ba50ae..686c864e275ed1 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { CopilotClient, CopilotSession, ReasoningSummary, Verbosity } from '@github/copilot-sdk'; +import type { CopilotClient, CopilotSession, ReasoningSummary, SessionConfig, Verbosity } from '@github/copilot-sdk'; import assert from 'assert'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; @@ -22,7 +22,7 @@ import { toClientPluginMcpDefaultCwdsMeta } from '../../common/meta/clientPlugin import { CopilotCliConfigKey, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; import type { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { reasoningEffortLevels } from '../../common/reasoningEffort.js'; -import { autoModeTiers, type AutoModeTier } from '../../common/autoModeTiers.js'; +import { autoModeTiers } from '../../common/autoModeTiers.js'; import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; import { CustomizationType, McpServerStatus, type ClientPluginCustomization, type ModelSelection } from '../../common/state/protocol/state.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; @@ -1125,10 +1125,7 @@ suite('resolveCopilotReasoningEffort', () => { }); }); -/** - * The Auto model's routing profile ("Optimize for"): only the `auto` entry routes per turn, and the - * host's `autoModeTiers` gate decides whether the profile ever reaches the runtime. - */ +/** Auto's "Optimize for" preference shares the extension's override and picker gate. */ suite('getCopilotAutoTier', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -1139,14 +1136,14 @@ suite('getCopilotAutoTier', () => { getCopilotAutoTier({ id: 'auto', config: { tier: 'intelligence' } }), // The picker's default is a real selection, forwarded like any other. getCopilotAutoTier({ id: 'auto', config: { tier: 'balance' } }), - // A retired tier name is not a runtime wire value, so sending it would be rejected. + // Retired selections are translated before they reach the runtime. getCopilotAutoTier({ id: 'auto', config: { tier: 'max' } }), getCopilotAutoTier({ id: 'auto' }), // A profile left on a concrete model by an earlier Auto selection. getCopilotAutoTier({ id: 'gpt-5', config: { tier: 'efficiency' } }), getCopilotAutoTier(undefined), ], - ['intelligence', 'balance', undefined, undefined, undefined, undefined] + ['intelligence', 'balance', 'intelligence', undefined, undefined, undefined] ); }); @@ -1159,22 +1156,28 @@ suite('getCopilotAutoTier', () => { ); }); - test('only forwards a profile while the gate is on', () => { + test('resolves the override before gated picker preferences', () => { const log = new NullLogService(); const model: ModelSelection = { id: 'auto', config: { tier: 'efficiency' } }; - const configOf = (autoModeTiers: boolean | undefined): Pick => - ({ getRootValue: (_schema, key) => (key === CopilotCliConfigKey.AutoModeTiers ? autoModeTiers : undefined) as never }); - + const configOf = (autoModeTiers: boolean | undefined, autoModeTierOverride?: string): Pick => ({ + getRootValue: (schema, key) => { + const value = copilotCliConfigSchema.values({ autoModeTiers, autoModeTierOverride })[key]; + return schema.validate(key, value) ? value : undefined; + }, + }); assert.deepStrictEqual( [ resolveCopilotAutoTier(model, configOf(true), log, 's1'), - // A profile persisted while the gate was on must not survive turning it off: the runtime - // rejects unknown `capi` fields, so an older runtime would fail the session outright. + // A saved selection must not bypass disabling the picker. resolveCopilotAutoTier(model, configOf(false), log, 's1'), resolveCopilotAutoTier(model, configOf(undefined), log, 's1'), resolveCopilotAutoTier({ id: 'gpt-5', config: { tier: 'efficiency' } }, configOf(true), log, 's1'), + resolveCopilotAutoTier(model, configOf(true, 'intelligence'), log, 's1'), + resolveCopilotAutoTier(model, configOf(false, 'balance'), log, 's1'), + resolveCopilotAutoTier(model, configOf(true, 'fast'), log, 's1'), + resolveCopilotAutoTier({ id: 'gpt-5' }, configOf(true, 'intelligence'), log, 's1'), ], - ['efficiency', undefined, undefined, undefined] + ['efficiency', undefined, undefined, undefined, 'intelligence', 'balance', 'efficiency', undefined] ); }); }); @@ -1519,34 +1522,37 @@ suite('CopilotSessionLauncher resume config', () => { }); }); -/** - * The routing profile is a create-time option: the runtime fixes it for the session's lifetime and - * restores it on cold resume, so re-sending it on resume would collide with the resident one. - */ +/** Resume preserves runtime-owned routing state; a turn's selection is applied through setModel. */ suite('CopilotSessionLauncher auto tier', () => { ensureNoDisposablesAreLeakedInTestSuite(); /** Launches a session and returns the `capi` options the SDK was called with. */ - async function capiOptionsFor(kind: 'create' | 'resume', autoTier: AutoModeTier | undefined): Promise { - let capi: unknown = 'not-called'; + async function capiOptionsFor(kind: 'create' | 'resume' | 'fallback', model: ModelSelection | undefined, enabled = true, override?: string): Promise { + const capiCalls: SessionConfig['capi'][] = []; const session = { sessionId: 'session-1', on: () => () => { }, disconnect: async () => { }, rpc: { options: { update: async () => ({ success: true }) } }, } as unknown as CopilotSession; - const client = { - createSession: async (config: { capi?: unknown }) => { - capi = config.capi; + const client: Pick = { + createSession: async config => { + capiCalls.push(config.capi); return session; }, - resumeSession: async (_sessionId: string, config: { capi?: unknown }) => { - capi = config.capi; + resumeSession: async (_sessionId, config) => { + capiCalls.push(config?.capi); + if (kind === 'fallback') { + throw Object.assign(new Error('Session has no events'), { code: -32603 }); + } return session; }, - } as unknown as Pick; - const launcher = createTestLauncher(); + }; + const launcher = createTestLauncher(undefined, { + [CopilotCliConfigKey.AutoModeTiers]: enabled, + [CopilotCliConfigKey.AutoModeTierOverride]: override, + }); const base = { client, sessionId: 'session-1', @@ -1558,8 +1564,8 @@ suite('CopilotSessionLauncher auto tier', () => { githubToken: undefined, }; const plan: CopilotSessionLaunchPlan = kind === 'create' - ? { ...base, kind: 'create', model: { id: 'auto' }, autoTier } - : { ...base, kind: 'resume', fallback: { model: { id: 'auto' }, autoTier } }; + ? { ...base, kind: 'create', model } + : { ...base, kind: 'resume', fallback: { model } }; const sessions = new DisposableStore(); try { @@ -1568,20 +1574,43 @@ suite('CopilotSessionLauncher auto tier', () => { sessions.dispose(); await launcher.disposeByokProxyHandle(); } - return capi; + return capiCalls; } - test('sends the plan profile verbatim on create, and never on resume', async () => { + test('resolves the override and gated picker preference when creating a session', async () => { + const model: ModelSelection = { id: 'auto', config: { tier: 'intelligence' } }; assert.deepStrictEqual( [ - // Sent exactly as frozen. The launcher must not resolve it again against the live gate. - await capiOptionsFor('create', 'intelligence'), - // No profile: omitted entirely, so a runtime without the contract never sees the field. + await capiOptionsFor('create', model), + await capiOptionsFor('create', model, false), + await capiOptionsFor('create', { id: 'auto' }), + await capiOptionsFor('create', { id: 'gpt-5', config: { tier: 'intelligence' } }), + await capiOptionsFor('create', { id: 'auto', config: { tier: 'max' } }), await capiOptionsFor('create', undefined), - // Resume keeps whatever profile the runtime journaled for the session. - await capiOptionsFor('resume', 'intelligence'), + await capiOptionsFor('create', model, false, 'balance'), + await capiOptionsFor('create', undefined, false, 'balance'), ], - [{ autoTier: 'intelligence' }, undefined, undefined] + [ + [{ autoTier: 'intelligence' }], [undefined], [undefined], [undefined], [{ autoTier: 'intelligence' }], [undefined], + [{ autoTier: 'balance' }], [{ autoTier: 'balance' }], + ] + ); + }); + + test('preserves runtime routing state on resume unless overridden, and handles empty-session fallback', async () => { + const model: ModelSelection = { id: 'auto', config: { tier: 'efficiency' } }; + assert.deepStrictEqual( + [ + await capiOptionsFor('resume', model), + await capiOptionsFor('fallback', model), + await capiOptionsFor('fallback', model, false), + await capiOptionsFor('resume', model, false, 'intelligence'), + await capiOptionsFor('fallback', model, false, 'intelligence'), + ], + [ + [undefined], [undefined, { autoTier: 'efficiency' }], [undefined, undefined], + [{ autoTier: 'intelligence' }], [{ autoTier: 'intelligence' }, { autoTier: 'intelligence' }], + ] ); }); }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 888acb6f617457..52cd8161712514 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -70,7 +70,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.sessionArchiveNudge', "An archive suggestion appears above the chat input when the session's pull requests are merged. Use Tab or Shift+Tab to reach Archive or Dismiss Archive Suggestion, then press Enter or Space. Dismissing the suggestion, including with Escape while it is focused, returns to the chat input. Archiving keeps the conversation available to you and agents. Use the session-list filter to find the session and unarchive it at any time. For worktree sessions, archiving cleans up the worktree and unarchiving recreates it.")); } if (type === 'panelChat' || type === 'quickChat' || type === 'editsView' || type === 'agentView') { - content.push(localize('chat.modelPicker.tiers', "In the tabbed model picker, the selected model's details open beside the list. Moving between model rows updates the details immediately without selecting a model. The card stays visible when the pointer leaves a row. Press Right Arrow from a model row to focus its details, and Left Arrow to return. When Auto routing tiers are available, the tiers remain visible while Auto is off. Use arrow keys to move between tiers, then Enter or Space to choose a tier and turn Auto on. Turning Auto off preserves the selected tier.")); + content.push(localize('chat.modelPicker.optimizeFor', "In the tabbed model picker, the selected model's details open beside the list. Moving between model rows updates the details immediately without selecting a model. The card stays visible when the pointer leaves a row. Press Right Arrow from a model row to focus its details, and Left Arrow to return. When Auto's \"Optimize for\" options are available, Efficiency, Balance, and Intelligence remain visible while Auto is off. Use arrow keys to move between options, then Enter or Space to choose a preference and turn Auto on. Turning Auto off preserves the selected preference.")); content.push(localize('chat.modelPicker.search', "Type while the model list is focused to search across all providers. In the search field, use Up and Down Arrow to navigate results, Enter to select a model, and Escape to close the picker. Left and Right Arrow move the text cursor.")); content.push(localize('chat.fileChangesDisclosure', 'File change summaries show the total files, additions, and deletions. Focus the disclosure and press Enter or Space to show or hide the individual files. Focus an additions and deletions label and press Enter or Space to open the changes in a diff editor.')); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.ts index c5d50edda9f620..0033b996e1ba5d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.ts @@ -8,7 +8,7 @@ import { autorun } from '../../../../../../base/common/observable.js'; import { isObject } from '../../../../../../base/common/types.js'; import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; -import { AgentHostAutoModeTiersEnabledSettingId, AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostShellToolInitScriptEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, CopilotCliConfigKey, CopilotSubagentModelGuidanceEnabledSettingId, normalizeToolSearchDeferThreshold, type CopilotCliModelCapabilityOverrides, type CopilotSdkLogLevelSetting } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; +import { AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostShellToolInitScriptEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, CopilotAutoModeTierOverrideSettingId, CopilotAutoModeTiersEnabledSettingId, CopilotCliConfigKey, CopilotSubagentModelGuidanceEnabledSettingId, normalizeToolSearchDeferThreshold, type CopilotCliModelCapabilityOverrides, type CopilotSdkLogLevelSetting } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IWorkbenchContribution } from '../../../../../../workbench/common/contributions.js'; import { AgentHostRootConfigForwarder, type IForwardedRootConfigKey } from './agentHostRootConfigForwarder.js'; @@ -65,8 +65,13 @@ export class AgentHostCopilotCliSettingsContribution extends Disposable implemen }, { key: CopilotCliConfigKey.AutoModeTiers, - computeValue: () => this._configurationService.getValue(AgentHostAutoModeTiersEnabledSettingId) === true, - registerTriggers: (store, push) => this._pushOnSettingChange(store, push, AgentHostAutoModeTiersEnabledSettingId), + computeValue: () => this._configurationService.getValue(CopilotAutoModeTiersEnabledSettingId) === true, + registerTriggers: (store, push) => this._pushOnSettingChange(store, push, CopilotAutoModeTiersEnabledSettingId), + }, + { + key: CopilotCliConfigKey.AutoModeTierOverride, + computeValue: () => this._configurationService.getValue(CopilotAutoModeTierOverrideSettingId) ?? '', + registerTriggers: (store, push) => this._pushOnSettingChange(store, push, CopilotAutoModeTierOverrideSettingId), }, { key: CopilotCliConfigKey.SubagentModelGuidance, diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index b82890e675081d..da4c34225c6df5 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -21,7 +21,7 @@ import { AgentHostAutoReplyEnabledConfigKey, AgentHostEditAutoApprovePatternsCon import '../../../../platform/agentHost/common/agentHostStarter.config.contribution.js'; import { AgentMergeSettingId } from '../../../../platform/agentHost/common/agentMerge.js'; import { AgentHostAhpJsonlLoggingSettingId, AgentHostAllowSignedOutWhenUsableSettingId, AgentHostSdkSandboxEnabledSettingId, AgentHostSdkSandboxWindowsEnabledSettingId, CodexPreferAgentHostEditorSettingId } from '../../../../platform/agentHost/common/agentService.js'; -import { AgentHostAutoModeTiersEnabledSettingId, AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostCustomTerminalToolEnabledSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningEffortOverrideSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostShellToolInitScriptEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, AutoModeTiersExperimentName, CopilotSubagentModelGuidanceEnabledSettingId, copilotSdkLogLevelSettingValues } from '../../../../platform/agentHost/common/copilotCliConfig.js'; +import { AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostCustomTerminalToolEnabledSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningEffortOverrideSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostShellToolInitScriptEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, AutoModeTiersExperimentName, CopilotAutoModeTierOverrideSettingId, CopilotAutoModeTiersEnabledSettingId, CopilotSubagentModelGuidanceEnabledSettingId, copilotSdkLogLevelSettingValues } from '../../../../platform/agentHost/common/copilotCliConfig.js'; import { CopilotSemanticSearchEnabledSettingId } from '../../../../platform/agentHost/common/semanticSearchConstants.js'; import { ChatMicrosoftAuthenticationEnabledSettingId, DEFAULT_EDIT_AUTO_APPROVE_PATTERNS, mergeChatEditAutoApprovePatterns } from '../../../../platform/chat/common/chatSettings.js'; import { reasoningEffortLevels } from '../../../../platform/agentHost/common/reasoningEffort.js'; @@ -1695,15 +1695,19 @@ configurationRegistry.registerConfiguration({ experiment: { mode: 'startup' }, tags: ['experimental', 'advanced'], }, - [AgentHostAutoModeTiersEnabledSettingId]: { + [CopilotAutoModeTiersEnabledSettingId]: { type: 'boolean', - markdownDescription: nls.localize('chat.agentHost.copilot.autoModeTiers', "When enabled, the Auto model in Copilot SDK agent sessions offers an \"Optimize for\" picker that biases routing toward efficiency, balance, or intelligence. The profile applies when a session is created and stays fixed for that session."), + markdownDescription: nls.localize('chat.copilot.autoModeTiers', "Show the Auto model's \"Optimize for\" picker with Efficiency, Balance, and Intelligence options in Copilot Chat and Copilot SDK sessions. When disabled, the service chooses how to route unless an override is configured."), default: false, - // The Copilot extension gates its own Auto tier picker on this same treatment, so one - // assignment turns tiers on for both harnesses. Mirrors HIDE_AUTO_EXPLAINABILITY_TREATMENT. experiment: { mode: 'startup', name: AutoModeTiersExperimentName }, tags: ['experimental', 'advanced'], }, + [CopilotAutoModeTierOverrideSettingId]: { + type: ['string', 'null'], + markdownDescription: nls.localize('chat.copilot.autoModeTierOverride', "Override Auto's \"Optimize for\" preference for evals and debugging, even when the picker is disabled. Accepts `efficiency`, `balance`, or `intelligence` in both Copilot Chat and Copilot SDK sessions; `fast` is extension-only. Set to `null` to clear."), + default: null, + tags: ['advanced'], + }, [CopilotSubagentModelGuidanceEnabledSettingId]: { type: 'boolean', markdownDescription: nls.localize('chat.copilot.subagentModelGuidance.enabled', "When enabled, Copilot SDK agent sessions instruct the model to keep subagents on their default model, setting the task tool's `model` parameter only when you explicitly name the model a subagent should run on. Applied when a session launches or resumes."), diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerCard.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerCard.ts index 0438c74edf0b62..bdeac09496f778 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerCard.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerCard.ts @@ -184,7 +184,7 @@ export class ModelCard extends DisposableStore { private _renderEffortSection(effort: IModelConfigProperty, isAuto: boolean): void { this._renderChoiceSection(effort, MODEL_CONFIG_GROUP_EFFORT, effort.schema.title ?? (isAuto - ? localize('models.routingProfile', "Routing Profile") + ? localize('models.optimizeFor', "Optimize for") : localize('chat.effort.header', "Thinking Effort"))); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts index 8135977feb7832..1b89413a19f350 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts @@ -154,15 +154,14 @@ export function getModelHoverContent( container.appendChild(contextSection); } - // Auto has no per-model pricing to show, but it does expose a routing tier, + // Auto has no per-model pricing to show, but it does expose an "Optimize for" preference, // so the configurable section is not gated on `isAuto`. if (model.metadata.configurationSchema?.properties) { const configButtons: { group: string; label: string }[] = []; const seenGroups = new Set(); for (const propSchema of Object.values(model.metadata.configurationSchema.properties)) { if (propSchema.enum && propSchema.enum.length >= 2 && propSchema.group && SUPPORTED_CONFIG_GROUPS.includes(propSchema.group) && !seenGroups.has(propSchema.group)) { - // Auto's navigation option is its routing tier; the menu keeps the producer's "Optimize for…" title. - const label = isAuto && propSchema.group === MODEL_CONFIG_GROUP_EFFORT ? localize('models.routingProfile', "Routing Profile") : propSchema.title ?? propSchema.description; + const label = isAuto && propSchema.group === MODEL_CONFIG_GROUP_EFFORT ? localize('models.optimizeFor', "Optimize for") : propSchema.title ?? propSchema.description; if (label) { seenGroups.add(propSchema.group); configButtons.push({ group: propSchema.group, label }); diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts index f2188dd3e49eb3..5e139f5e9d52f3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts @@ -13,14 +13,14 @@ import { AGENT_SESSION_RENAME_ACTION_ID } from '../../../browser/agentSessions/a suite('Chat Accessibility Help', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('documents model details and activating Auto through its tiers', () => { + test('documents model details and activating Auto through Optimize for', () => { const help = getAccessibilityHelpText('agentView', new MockKeybindingService(), true); assert.deepStrictEqual({ details: help.includes('selected model\'s details open beside the list'), immediatePreview: help.includes('updates the details immediately without selecting a model'), - inactiveTiers: help.includes('tiers remain visible while Auto is off'), - activation: help.includes('Enter or Space to choose a tier and turn Auto on'), - }, { details: true, immediatePreview: true, inactiveTiers: true, activation: true }); + inactivePreferences: help.includes('Efficiency, Balance, and Intelligence remain visible while Auto is off'), + activation: help.includes('Enter or Space to choose a preference and turn Auto on'), + }, { details: true, immediatePreview: true, inactivePreferences: true, activation: true }); }); test('documents keyboard search in the model picker', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts index f1d02b55eafe0a..29ef1836893773 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts @@ -11,7 +11,7 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; -import { AgentHostAutoModeTiersEnabledSettingId, AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostShellToolInitScriptEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, CopilotCliConfigKey, CopilotSubagentModelGuidanceEnabledSettingId } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; +import { AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostShellToolInitScriptEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, CopilotCliConfigKey, CopilotSubagentModelGuidanceEnabledSettingId } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ClientAnnotationsAction, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { ConfigPropertySchema, RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -77,7 +77,8 @@ const fullSchema: Record = { [CopilotCliConfigKey.ToolSearchDeferThreshold]: { type: 'number', title: 'Tool Search Defer Threshold' }, [CopilotCliConfigKey.ReasoningSummary]: { type: 'boolean', title: 'Reasoning Summary' }, [CopilotCliConfigKey.MultiTurnContextRouting]: { type: 'boolean', title: 'Auto Multi-Turn Context Routing' }, - [CopilotCliConfigKey.AutoModeTiers]: { type: 'boolean', title: 'Auto Routing Profiles' }, + [CopilotCliConfigKey.AutoModeTiers]: { type: 'boolean', title: 'Auto Optimize for' }, + [CopilotCliConfigKey.AutoModeTierOverride]: { type: 'string', title: 'Auto Optimize for Override' }, [CopilotCliConfigKey.SubagentModelGuidance]: { type: 'boolean', title: 'Subagent Model Guidance' }, [CopilotCliConfigKey.ModelCapabilityOverrides]: { type: 'object', title: 'Model Capability Overrides' }, [CopilotCliConfigKey.EnableShellInitScript]: { type: 'boolean', title: 'Shell Init Script' }, @@ -94,6 +95,7 @@ function setup(disposables: DisposableStore, settings: Record) const agentHostService = new MockAgentHostService(); disposables.add({ dispose: () => agentHostService.dispose() }); const configurationService = new TestConfigurationService(settings); + disposables.add(configurationService.onDidChangeConfigurationEmitter); instantiationService.stub(IAgentHostService, agentHostService); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false) }); @@ -124,7 +126,8 @@ suite('AgentHostCopilotCliSettingsContribution', () => { [AgentHostCopilotModelCapabilityOverridesSettingId]: capabilityOverrides, [AgentHostReasoningSummaryEnabledSettingId]: true, [AgentHostMultiTurnContextRoutingEnabledSettingId]: true, - [AgentHostAutoModeTiersEnabledSettingId]: true, + 'github.copilot.chat.autoMode.tiers.enabled': true, + 'github.copilot.chat.autoModeTierOverride': 'intelligence', [CopilotSubagentModelGuidanceEnabledSettingId]: true, [AgentHostShellToolInitScriptEnabledSettingId]: true, }); @@ -133,7 +136,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { // The shared forwarder dispatches one RootConfigChanged per key; merge them // and assert the full forwarded set (order-independent). - assert.strictEqual(agentHostService.dispatchedActions.length, 10); + assert.strictEqual(agentHostService.dispatchedActions.length, 11); const merged = Object.assign({}, ...agentHostService.dispatchedActions.map(a => (a.action as IRootConfigChangedAction).config)); assert.deepStrictEqual(merged, { [CopilotCliConfigKey.CopilotSdkLogLevel]: 'trace', @@ -143,6 +146,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { [CopilotCliConfigKey.ReasoningSummary]: true, [CopilotCliConfigKey.MultiTurnContextRouting]: true, [CopilotCliConfigKey.AutoModeTiers]: true, + [CopilotCliConfigKey.AutoModeTierOverride]: 'intelligence', [CopilotCliConfigKey.SubagentModelGuidance]: true, [CopilotCliConfigKey.ModelCapabilityOverrides]: capabilityOverrides, [CopilotCliConfigKey.EnableShellInitScript]: true, @@ -192,6 +196,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { [AgentHostOpus48PromptEnabledSettingId]: true, [AgentHostCopilotModelCapabilityOverridesSettingId]: { 'preview-model-x': { family: 'claude-opus-4-8' } }, [AgentHostReasoningSummaryEnabledSettingId]: false, + 'github.copilot.chat.autoMode.tiers.enabled': false, }); agentHostService.setRootState(makeRootStateWithSchema(fullSchema, { [CopilotCliConfigKey.CopilotSdkLogLevel]: 'trace', @@ -201,6 +206,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { [CopilotCliConfigKey.ReasoningSummary]: false, [CopilotCliConfigKey.MultiTurnContextRouting]: false, [CopilotCliConfigKey.AutoModeTiers]: false, + [CopilotCliConfigKey.AutoModeTierOverride]: '', [CopilotCliConfigKey.SubagentModelGuidance]: false, [CopilotCliConfigKey.ModelCapabilityOverrides]: { 'preview-model-x': { family: 'claude-opus-4-8' } }, [CopilotCliConfigKey.EnableShellInitScript]: false, diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerHover.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerHover.test.ts index a56eec6f971c70..cb362e25b3fc9d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerHover.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerHover.test.ts @@ -98,7 +98,7 @@ suite('ModelPickerHover', () => { }); }); - test('auto names its navigation option the routing profile, other models keep the schema title', () => { + test('auto names its navigation option Optimize for, other models keep the schema title', () => { const results = ['auto', 'gpt-5'].map(id => { const model = createModel(id, id); model.metadata = { @@ -107,8 +107,8 @@ suite('ModelPickerHover', () => { properties: { navigationOption: { type: 'string', - title: 'Optimize for', - enum: ['eco', 'max'], + title: id === 'auto' ? undefined : 'Thinking Effort', + enum: ['efficiency', 'intelligence'], group: 'navigation', }, contextSize: { @@ -128,8 +128,8 @@ suite('ModelPickerHover', () => { }); assert.deepStrictEqual(results, [ - ['Routing Profile', 'Context Size'], ['Optimize for', 'Context Size'], + ['Thinking Effort', 'Context Size'], ]); }); }); From 27fcd422bf23efd1db874aec98a445f350c2a89a Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Tue, 8 Sep 2026 15:33:13 -0400 Subject: [PATCH 2/3] copilot: remove unused Auto-mode experimentation dependency Auto-tier support now reads the shared effective configuration, so remove the obsolete experimentation-service injection and assert that the regression test performs no separate experiment lookup. Fixes the extension typecheck failure on #335103. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 61130d6b-7319-480e-92d1-b6b08949e03a --- .../src/platform/endpoint/node/automodeService.ts | 2 -- .../endpoint/node/test/automodeService.spec.ts | 12 ++++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/extensions/copilot/src/platform/endpoint/node/automodeService.ts b/extensions/copilot/src/platform/endpoint/node/automodeService.ts index 8ca557cb2c7a6a..312c9eed43481a 100644 --- a/extensions/copilot/src/platform/endpoint/node/automodeService.ts +++ b/extensions/copilot/src/platform/endpoint/node/automodeService.ts @@ -16,7 +16,6 @@ import { ILogService } from '../../log/common/logService'; import { isAbortError } from '../../networking/common/fetcherService'; import { IChatEndpoint } from '../../networking/common/networking'; import { IRequestLogger } from '../../requestLogger/common/requestLogger'; -import { IExperimentationService } from '../../telemetry/common/nullExperimentationService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; import { AUTO_MODE_TIER_PROPERTY, autoModeTiers, defaultAutoModeTier, inlineChatAutoModeTier, isSelectableAutoModeTier, normalizeAutoModeTier, type AutoModeTier } from '../common/autoModeTiers'; import { ICAPIClientService } from '../common/capiClient'; @@ -162,7 +161,6 @@ export class AutomodeService extends Disposable implements IAutomodeService { @IAuthenticationService private readonly _authService: IAuthenticationService, @ILogService private readonly _logService: ILogService, @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IExperimentationService private readonly _expService: IExperimentationService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IRequestLogger private readonly _requestLogger: IRequestLogger, @IConfigurationService private readonly _configurationService: IConfigurationService, diff --git a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts index cd33dfacf5bb3d..d873a99dea9e3b 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts @@ -17,7 +17,6 @@ import { createPngBytes } from '../../../image/common/test/testImageData'; import { ILogService } from '../../../log/common/logService'; import { IChatEndpoint } from '../../../networking/common/networking'; import { NullRequestLogger } from '../../../requestLogger/node/nullRequestLogger'; -import { IExperimentationService, NullExperimentationService } from '../../../telemetry/common/nullExperimentationService'; import { ITelemetryService } from '../../../telemetry/common/telemetry'; import { defaultAutoModeTier } from '../../common/autoModeTiers'; import { ICAPIClientService } from '../../common/capiClient'; @@ -37,7 +36,6 @@ describe('AutomodeService', () => { let mockAuthService: IAuthenticationService; let mockLogService: ILogService; let mockInstantiationService: IInstantiationService; - let mockExpService: IExperimentationService; let mockChatEndpoint: IChatEndpoint; let configurationService: IConfigurationService; let onDidAuthenticationChangeEmitter: Emitter; @@ -66,7 +64,6 @@ describe('AutomodeService', () => { mockAuthService, mockLogService, mockInstantiationService, - mockExpService, mockTelemetryService, new NullRequestLogger(), configurationService @@ -158,7 +155,6 @@ describe('AutomodeService', () => { ) } as unknown as IInstantiationService; - mockExpService = new NullExperimentationService(); configure(); mockTelemetryService = { sendTelemetryEvent: vi.fn(), @@ -748,7 +744,7 @@ describe('AutomodeService', () => { }); it('announces tier support from the effective setting, not a separate experiment lookup', async () => { - vi.spyOn(mockExpService, 'getTreatmentVariable').mockReturnValue(true); + const getExperimentBasedConfig = vi.spyOn(configurationService, 'getExperimentBasedConfig'); automodeService = createService(); expect(automodeService.areAutoModeTiersSupported()).toBe(false); @@ -760,7 +756,11 @@ describe('AutomodeService', () => { await configurationService.setConfig(ConfigKey.Advanced.AutoModeTiersEnabled, false); listener.dispose(); - expect({ announced, supported: automodeService.areAutoModeTiersSupported() }).toEqual({ announced: 2, supported: false }); + expect({ + announced, + supported: automodeService.areAutoModeTiersSupported(), + experimentLookups: getExperimentBasedConfig.mock.calls.length, + }).toEqual({ announced: 2, supported: false, experimentLookups: 0 }); }); it('does not reuse a cached endpoint from a different tier when /auto fails', async () => { From 5c8c130ffcf0f5655a279a09b508e81bfd5c5d77 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Tue, 8 Sep 2026 16:04:10 -0400 Subject: [PATCH 3/3] copilot: classify workbench-owned Auto settings as shared Keep the centralized Auto-tier settings in the existing shared configuration category rather than the extension-owned advanced category. Preserve their IDs and defaults, update consumers, and keep the strict package.json registration test unchanged. Fixes the remaining Copilot unit-test failure on #335103. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 61130d6b-7319-480e-92d1-b6b08949e03a --- .../configuration/common/configurationService.ts | 15 +++++---------- .../src/platform/endpoint/common/autoModeTiers.ts | 2 +- .../src/platform/endpoint/node/automodeService.ts | 4 ++-- .../endpoint/node/test/automodeService.spec.ts | 14 +++++++------- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 776b05e7e47691..85eb399f1c5878 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -698,11 +698,7 @@ export const XTabProviderId = 'XtabProvider'; export namespace ConfigKey { - /** - * These settings are defined in the completions extensions and shared. - * - * We should not change the names of these settings without coordinating with Completions extension. - */ + /** Settings owned by VS Code or the completions extension; coordinate identifier changes with their owner. */ export namespace Shared { /** Allows for overriding the base domain we use for making requests to the CAPI. This helps CAPI devs develop against a local instance. */ export const DebugOverrideProxyUrl = defineSetting('advanced.debug.overrideProxyUrl', ConfigType.Simple, undefined, undefined, { userScopeOnly: true }); @@ -715,6 +711,10 @@ export namespace ConfigKey { export const DebugNodeFetchCache = defineSetting<'off' | 'memory' | 'persistent'>('advanced.debug.nodeFetchCache', ConfigType.Simple, 'memory'); export const AuthProvider = defineSetting('advanced.authProvider', ConfigType.Simple, AuthProviderId.GitHub); export const AuthPermissions = defineSetting('advanced.authPermissions', ConfigType.Simple, AuthPermissionMode.Default); + /** The workbench owns the experiment default so all Auto pickers read the same effective setting. */ + export const AutoModeTiersEnabled = defineSetting('chat.autoMode.tiers.enabled', ConfigType.Simple, false); + /** Override Auto's "Optimize for" preference in both harnesses; the extension additionally accepts `fast`. */ + export const AutoModeTierOverride = defineSetting('chat.autoModeTierOverride', ConfigType.Simple, null); } /** @@ -746,8 +746,6 @@ export namespace ConfigKey { export const OmitBaseAgentInstructions = defineAndMigrateSetting('chat.advanced.omitBaseAgentInstructions', 'chat.omitBaseAgentInstructions', false); export const CLIPlanExitModeEnabled = defineSetting('chat.cli.planExitMode.enabled', ConfigType.Simple, true); export const CLIAutoModelEnabled = defineSetting('chat.cli.autoModel.enabled', ConfigType.Simple, true); - /** The workbench owns the experiment default so all Auto pickers read the same effective setting. */ - export const AutoModeTiersEnabled = defineSetting('chat.autoMode.tiers.enabled', ConfigType.Simple, false); export const CLIModelDetailsEnabled = defineSetting('chat.agent.modelDetails.enabled', ConfigType.Simple, true); export const CLIPlanCommandEnabled = defineSetting('chat.cli.planCommand.enabled', ConfigType.Simple, true); export const CLIChatLazyLoadSessionItem = defineSetting('chat.cli.lazyLoadSessionItem.enabled', ConfigType.Simple, true); @@ -868,9 +866,6 @@ export namespace ConfigKey { export const ReasoningEffortOverride = defineSetting('chat.reasoningEffortOverride', ConfigType.Simple, null); export const ChatCompletionsTokenParameter = defineSetting('chat.chatCompletionsTokenParameter', ConfigType.ExperimentBased, 'max_tokens', vEnum('max_completion_tokens', 'max_tokens')); - /** Override Auto's "Optimize for" preference in both harnesses; the extension additionally accepts `fast`. */ - export const AutoModeTierOverride = defineSetting('chat.autoModeTierOverride', ConfigType.Simple, null); - /** * When enabled, periodic keep-alive probes are sent during long-running tool calls * to keep the server-side prompt cache warm. diff --git a/extensions/copilot/src/platform/endpoint/common/autoModeTiers.ts b/extensions/copilot/src/platform/endpoint/common/autoModeTiers.ts index ba23d46ee35eb1..69b6b71d72f5fe 100644 --- a/extensions/copilot/src/platform/endpoint/common/autoModeTiers.ts +++ b/extensions/copilot/src/platform/endpoint/common/autoModeTiers.ts @@ -15,7 +15,7 @@ export type AutoModeTier = typeof autoModeTiers[number]; * The tiers offered in the model picker. `fast` is excluded: it is the profile * inline chat falls back to when the user has not picked a tier, and is not * offered as a choice. It remains reachable through the internal - * {@link ConfigKey.Advanced.AutoModeTierOverride} setting. + * {@link ConfigKey.Shared.AutoModeTierOverride} setting. */ export const selectableAutoModeTiers: readonly AutoModeTier[] = ['efficiency', 'balance', 'intelligence']; diff --git a/extensions/copilot/src/platform/endpoint/node/automodeService.ts b/extensions/copilot/src/platform/endpoint/node/automodeService.ts index 312c9eed43481a..7a645bc9de3152 100644 --- a/extensions/copilot/src/platform/endpoint/node/automodeService.ts +++ b/extensions/copilot/src/platform/endpoint/node/automodeService.ts @@ -362,7 +362,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { } areAutoModeTiersSupported(): boolean { - return this._configurationService.getConfig(ConfigKey.Advanced.AutoModeTiersEnabled); + return this._configurationService.getConfig(ConfigKey.Shared.AutoModeTiersEnabled); } /** @@ -400,7 +400,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { * pin below unreachable. */ private _resolveTier(chatRequest: IAutoModeRoutingRequest | undefined): AutoModeTier | undefined { - const override = this._configurationService.getConfig(ConfigKey.Advanced.AutoModeTierOverride); + const override = this._configurationService.getConfig(ConfigKey.Shared.AutoModeTierOverride); if (override) { const normalized = normalizeAutoModeTier(override); // The override is internal, so unlike the picker it may select `fast`. diff --git a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts index d873a99dea9e3b..d834556ee47352 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts @@ -76,11 +76,11 @@ describe('AutomodeService', () => { /** Tiers are experiment-gated and off by default, so tier tests opt in. */ function enableTiers(): void { - configure(new Map, unknown>([[ConfigKey.Advanced.AutoModeTiersEnabled, true]])); + configure(new Map, unknown>([[ConfigKey.Shared.AutoModeTiersEnabled, true]])); } function setTierOverride(override: string): void { - configure(new Map, unknown>([[ConfigKey.Advanced.AutoModeTierOverride, override]])); + configure(new Map, unknown>([[ConfigKey.Shared.AutoModeTierOverride, override]])); } function makeAutoResponse(body: unknown, status = 200) { @@ -729,8 +729,8 @@ describe('AutomodeService', () => { mockAuto(autoResponse('gpt-4o')); configure(new Map, unknown>([ - [ConfigKey.Advanced.AutoModeTiersEnabled, true], - [ConfigKey.Advanced.AutoModeTierOverride, 'turbo'], + [ConfigKey.Shared.AutoModeTiersEnabled, true], + [ConfigKey.Shared.AutoModeTierOverride, 'turbo'], ])); automodeService = createService(); await automodeService.resolveAutoModeEndpoint({ @@ -750,10 +750,10 @@ describe('AutomodeService', () => { let announced = 0; const listener = automodeService.onDidChangeAutoModeTierSupport(() => announced++); - await configurationService.setConfig(ConfigKey.Advanced.AutoModeTiersEnabled, true); + await configurationService.setConfig(ConfigKey.Shared.AutoModeTiersEnabled, true); // An unrelated change must not re-announce. - await configurationService.setConfig(ConfigKey.Advanced.AutoModeTierOverride, 'intelligence'); - await configurationService.setConfig(ConfigKey.Advanced.AutoModeTiersEnabled, false); + await configurationService.setConfig(ConfigKey.Shared.AutoModeTierOverride, 'intelligence'); + await configurationService.setConfig(ConfigKey.Shared.AutoModeTiersEnabled, false); listener.dispose(); expect({