From a22d0b64fa62922c1baf8f2530c7f06f9a02f271 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Mon, 22 Jun 2026 15:42:06 -0700 Subject: [PATCH 01/18] first commit --- .../platform/agentHost/common/agentService.ts | 7 ++ .../state/protocol/channels-root/state.ts | 14 +++ .../agentHost/node/agentSideEffects.ts | 1 + .../agentHost/node/copilot/copilotAgent.ts | 72 +++++++++------- .../node/copilot/copilotSessionLauncher.ts | 38 ++++++--- .../test/node/agentSideEffects.test.ts | 1 + .../agentHost/test/node/copilotAgent.test.ts | 72 +++++++++++++--- .../agentHostLanguageModelProvider.ts | 85 +++++++++++++++---- .../agentHost/agentHostSessionHandler.ts | 26 +++++- .../agentHostChatContribution.test.ts | 68 ++++++++++++++- 10 files changed, 311 insertions(+), 73 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 3de8693ee6d39..8ec4c5907a8f9 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -673,6 +673,13 @@ export interface IAgentModelInfo { readonly id: string; readonly name: string; readonly maxContextWindow?: number; + /** + * The context-window sizes (in tokens) this model recommends as user-selectable options, smallest + * first. The first entry is the recommended default. Present only when the model offers more than + * one window. Clients render these as a context-size picker and send the chosen value back via + * {@link ModelSelection.maxContextWindow}. + */ + readonly recommendedContextWindows?: readonly number[]; readonly supportsVision: boolean; readonly configSchema?: ConfigSchema; readonly policyState?: PolicyState; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index e9d897bde9ba3..fdddd81e962ff 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -97,6 +97,13 @@ export interface SessionModelInfo { name: string; /** Maximum context window size */ maxContextWindow?: number; + /** + * Context-window sizes (in tokens) this model recommends as user-selectable options, smallest + * first. The first entry is the recommended default. Present only when the model offers more than + * one window. Clients render these as a context-size picker and send the chosen value back via + * {@link ModelSelection.maxContextWindow}. + */ + recommendedContextWindows?: readonly number[]; /** Whether the model supports vision */ supportsVision?: boolean; /** Policy configuration state */ @@ -128,6 +135,13 @@ export interface ModelSelection { id: string; /** Model-specific configuration values */ config?: Record; + /** + * The context-window size (in tokens) the user selected for this model, chosen from the + * windows the model offers (see {@link SessionModelInfo.maxContextWindow} for the model's true + * maximum). The agent maps this to whatever backend representation it uses (e.g. a context + * tier). Absent when the model exposes no selectable windows or the user kept the default. + */ + maxContextWindow?: number; } // ─── Root Config Types ─────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 0cb3f7fa50f59..4fc93ea0349dc 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -176,6 +176,7 @@ export class AgentSideEffects extends Disposable { provider: m.provider, name: m.name, maxContextWindow: m.maxContextWindow, + recommendedContextWindows: m.recommendedContextWindows, supportsVision: m.supportsVision, policyState: m.policyState, configSchema: m.configSchema, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index c81b133cdd038..05cf2cdbf0612 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -14,7 +14,6 @@ import { appendEscapedMarkdownInlineCode } from '../../../../base/common/htmlCon import { Disposable, DisposableMap, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { FileAccess } from '../../../../base/common/network.js'; -import { formatTokenCount } from '../../../../base/common/numbers.js'; import { equals } from '../../../../base/common/objects.js'; import { observableValue } from '../../../../base/common/observable.js'; import { basename, delimiter, dirname, join } from '../../../../base/common/path.js'; @@ -52,7 +51,7 @@ import { ICopilotBranchNameGenerator } from './copilotBranchNameGenerator.js'; import { CopilotAgentSession, type CopilotSdkMode } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; -import { CopilotSessionLauncher, ContextTierConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +import { CopilotSessionLauncher, ThinkingLevelConfigKey, getCopilotReasoningEffort, mapContextSizeToContextTier, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { ShellManager } from './copilotShellTools.js'; import { isRestrictedTelemetryEnabled } from './copilotTokenFields.js'; import { CopilotSlashCommandCompletionProvider } from './copilotSlashCommandCompletionProvider.js'; @@ -127,6 +126,7 @@ type ModelInfo = Awaited>['mo interface ISerializedModelSelection { id?: unknown; config?: unknown; + maxContextWindow?: unknown; } /** @@ -698,38 +698,49 @@ export class CopilotAgent extends Disposable implements IAgent { } /** - * Synthesize a `contextTier` config property when the model exposes a `long_context` pricing tier with a distinct - * context-max. Picker surfaces this as the "Context Size" button. Mirrors `getContextSizeOptions` in + * Computes the context-window sizes (in tokens) a model recommends as user-selectable options when + * it exposes a `long_context` pricing tier whose context-max is strictly larger than its default + * tier: `[defaultMax, longContextMax]`, smallest first. Returns `undefined` when the model has no + * such distinction (so no picker is offered). Surfaced on {@link IAgentModelInfo.recommendedContextWindows}; + * the chosen value flows back via {@link ModelSelection.maxContextWindow} and is mapped to the SDK's + * `contextTier` by {@link _resolveContextTier}. Mirrors `getContextSizeOptions` in * `extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts`. * * `billing.tokenPrices` is present on the runtime CAPI `/models` payload but not yet declared on the published SDK * `ModelBilling` type — narrow through {@link ICopilotModelBilling} until the SDK catches up. */ - private _createContextTierConfigSchemaProperty(billing: ModelInfo['billing'] | undefined): ConfigPropertySchema | undefined { + private _getRecommendedContextWindows(billing: ModelInfo['billing'] | undefined): number[] | undefined { const tokenPrices = billing?.tokenPrices; const defaultMax = tokenPrices?.contextMax; const longContextMax = tokenPrices?.longContext?.contextMax; if (!defaultMax || !longContextMax || defaultMax >= longContextMax) { return undefined; } + return [defaultMax, longContextMax]; + } - const hasLongContextSurcharge = typeof tokenPrices?.longContext?.inputPrice === 'number' - || typeof tokenPrices?.longContext?.outputPrice === 'number'; - - return { - type: 'string', - title: localize('copilot.modelContextTier.title', "Context Size"), - description: localize('copilot.modelContextTier.description', "Selects the context window size for this model."), - default: 'default', - enum: ['default', 'long_context'], - enumLabels: [formatTokenCount(defaultMax), formatTokenCount(longContextMax)], - enumDescriptions: [ - localize('copilot.modelContextTier.default', "Default"), - hasLongContextSurcharge - ? localize('copilot.modelContextTier.longerSessions', "Longer sessions") - : localize('copilot.modelContextTier.longerSessionsNoCompaction', "Longer sessions without compaction"), - ], - }; + /** + * Maps a model selection's chosen {@link ModelSelection.maxContextWindow} (a token count) to the + * SDK's `contextTier`. The value must be one of the model's + * {@link IAgentModelInfo.recommendedContextWindows}; the smallest recommended window is the default + * tier and anything larger opts into `long_context`. A value the model does not recommend is + * rejected (ignored, leaving the SDK on its default tier), so a stale or out-of-range client + * selection cannot silently request an unsupported window. + */ + private _resolveContextTier(model: ModelSelection | undefined): ReturnType { + const selectedWindow = model?.maxContextWindow; + if (typeof selectedWindow !== 'number' || !model) { + return undefined; + } + const windows = this._models.get().find(m => m.id === model.id)?.recommendedContextWindows; + if (!windows || windows.length === 0) { + return undefined; + } + if (!windows.includes(selectedWindow)) { + this._logService.warn(`[Copilot] Ignoring unsupported context window ${selectedWindow} for model '${model.id}'; expected one of [${windows.join(', ')}]`); + return undefined; + } + return mapContextSizeToContextTier(selectedWindow, Math.min(...windows)); } /** @@ -770,10 +781,6 @@ export class CopilotAgent extends Disposable implements IAgent { if (thinkingLevel) { properties[ThinkingLevelConfigKey] = thinkingLevel; } - const contextTier = this._createContextTierConfigSchemaProperty(m.billing); - if (contextTier) { - properties[ContextTierConfigKey] = contextTier; - } if (Object.keys(properties).length === 0) { return undefined; } @@ -804,6 +811,9 @@ export class CopilotAgent extends Disposable implements IAgent { modelSelection.config = config; } } + if (typeof value.maxContextWindow === 'number') { + modelSelection.maxContextWindow = value.maxContextWindow; + } return modelSelection; } } catch { @@ -934,6 +944,7 @@ export class CopilotAgent extends Disposable implements IAgent { // Synthetic SDK entries like `auto` ship with `capabilities: {}` and // no fixed context window — surface them with maxContextWindow undefined. maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, + recommendedContextWindows: this._getRecommendedContextWindows(m.billing), supportsVision: !!m.capabilities?.supports?.vision, configSchema: this._createModelConfigSchema(m), policyState: m.policy?.state as PolicyState | undefined, @@ -1154,6 +1165,7 @@ export class CopilotAgent extends Disposable implements IAgent { shellManager, githubToken: this._githubToken, model: provisional.model, + contextTier: this._resolveContextTier(provisional.model), }; agentSession = this._createAgentSession(launchPlan, customizationDirectory, activeClient); await agentSession.initializeSession(); @@ -1644,6 +1656,7 @@ export class CopilotAgent extends Disposable implements IAgent { shellManager, githubToken: this._githubToken, model: options?.model, + contextTier: this._resolveContextTier(options?.model), }; let agentSession: CopilotAgentSession | undefined; try { @@ -1757,7 +1770,7 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientState: activeClient.state, shellManager, githubToken: this._githubToken, - fallback: { model: info.model }, + fallback: { model: info.model, contextTier: this._resolveContextTier(info.model) }, }; let agentSession: CopilotAgentSession | undefined; try { @@ -1813,7 +1826,7 @@ export class CopilotAgent extends Disposable implements IAgent { // skip the session-level metadata store (peer chats are not persisted // per-chat). if (chat && !isDefaultChatUri(chat)) { - await this._chatSessions.get(chat.toString())?.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model)); + await this._chatSessions.get(chat.toString())?.setModel(model.id, getCopilotReasoningEffort(model), this._resolveContextTier(model)); return; } const sessionId = AgentSession.id(session); @@ -1824,7 +1837,7 @@ export class CopilotAgent extends Disposable implements IAgent { } const entry = this._sessions.get(sessionId); if (entry) { - await entry.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model)); + await entry.setModel(model.id, getCopilotReasoningEffort(model), this._resolveContextTier(model)); } await this._storeSessionMetadata(session, model, undefined, undefined, undefined); } @@ -2072,6 +2085,7 @@ export class CopilotAgent extends Disposable implements IAgent { githubToken: this._githubToken, fallback: { model: storedMetadata.model, + contextTier: this._resolveContextTier(storedMetadata.model), }, }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 97470ca6b711d..2a5425a91c8e3 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -26,14 +26,10 @@ import { agentHostPromptRegistry, type IAgentHostPromptContext } from './prompts import './prompts/allPrompts.js'; export const ThinkingLevelConfigKey = 'thinkingLevel'; -export const ContextTierConfigKey = 'contextTier'; const ReasoningEfforts = ['low', 'medium', 'high', 'xhigh'] as const; type ReasoningEffort = NonNullable; -const ContextTiers = ['default', 'long_context'] as const; -type ContextTier = NonNullable; - type UserInputHandler = NonNullable; type UserInputRequest = Parameters[0]; type UserInputInvocation = Parameters[1]; @@ -108,6 +104,12 @@ interface ICopilotSessionLaunchBase { export interface ICopilotCreateSessionLaunchPlan extends ICopilotSessionLaunchBase { readonly kind: 'create'; readonly model: ModelSelection | undefined; + /** + * The SDK context tier resolved from the model selection's numeric `contextSize`. Resolved by + * the agent (which owns the model list / window sizes) rather than recomputed here, since the + * number→tier mapping needs the model's `maxContextWindow`. + */ + readonly contextTier?: SessionConfig['contextTier']; } export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBase { @@ -115,19 +117,17 @@ export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBa readonly workingDirectory: URI; readonly fallback: { readonly model: ModelSelection | undefined; + /** Resolved SDK context tier for {@link fallback.model}; see {@link ICopilotCreateSessionLaunchPlan.contextTier}. */ + readonly contextTier?: SessionConfig['contextTier']; }; } export type CopilotSessionLaunchPlan = ICopilotCreateSessionLaunchPlan | ICopilotResumeSessionLaunchPlan; -function isReasoningEffort(value: string | undefined): value is ReasoningEffort { +function isReasoningEffort(value: string | number | undefined): value is ReasoningEffort { return ReasoningEfforts.some(reasoningEffort => reasoningEffort === value); } -function isContextTier(value: string | undefined): value is ContextTier { - return ContextTiers.some(contextTier => contextTier === value); -} - function getCopilotSdkErrorCode(err: unknown): number | undefined { if (typeof err !== 'object' || err === null) { return undefined; @@ -177,9 +177,20 @@ export function getCopilotReasoningEffort(model: ModelSelection | undefined): Se return isReasoningEffort(thinkingLevel) ? thinkingLevel : undefined; } -export function getCopilotContextTier(model: ModelSelection | undefined): SessionConfig['contextTier'] { - const contextTier = model?.config?.[ContextTierConfigKey]; - return isContextTier(contextTier) ? contextTier : undefined; +/** + * Maps a user-selected context-window size (in tokens) to the SDK's two-valued + * {@link SessionConfig.contextTier}. The model offers exactly two windows — the default-tier window + * and the larger long-context window — so any selection above the default-tier window opts into + * `long_context`. + * + * Returns `undefined` when no size was selected or the default-tier window is unknown, leaving the + * SDK on its default tier. + */ +export function mapContextSizeToContextTier(selectedWindow: number | undefined, defaultContextWindow: number | undefined): SessionConfig['contextTier'] { + if (typeof selectedWindow !== 'number' || typeof defaultContextWindow !== 'number') { + return undefined; + } + return selectedWindow > defaultContextWindow ? 'long_context' : 'default'; } export class CopilotSessionLauncher implements ICopilotSessionLauncher { @@ -224,6 +235,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { ...plan, kind: 'create', model: plan.fallback.model, + contextTier: plan.fallback.contextTier, }, config, sandboxConfig); this._logService.info(`[Copilot:${plan.sessionId}] Fallback createSession succeeded`); return wrapper; @@ -237,7 +249,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { streaming: true, model: plan.model?.id, reasoningEffort: getCopilotReasoningEffort(plan.model), - contextTier: getCopilotContextTier(plan.model), + contextTier: plan.contextTier, ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), workingDirectory: plan.workingDirectory?.fsPath, }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 01a10fbcedb89..7b33e16e935a6 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -718,6 +718,7 @@ suite('AgentSideEffects', () => { provider: 'mock', name: 'mock Model', maxContextWindow: 128000, + recommendedContextWindows: undefined, supportsVision: false, policyState: undefined, configSchema: undefined, diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 1802e1acbb595..f5528f417811d 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -846,6 +846,7 @@ suite('CopilotAgent', () => { id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128000, + recommendedContextWindows: undefined, supportsVision: true, configSchema: undefined, policyState: undefined, @@ -911,18 +912,18 @@ suite('CopilotAgent', () => { const schema = models[0].configSchema; assert.deepStrictEqual(schema?.properties.thinkingLevel?.enum, ['low', 'medium', 'high']); assert.strictEqual(schema?.properties.thinkingLevel?.default, 'medium'); - assert.strictEqual(schema?.properties.contextTier, undefined); + assert.strictEqual(schema?.properties.contextSize, undefined); } finally { await disposeAgent(agent); } }); - test('configSchema emits a contextTier property when long_context tier exceeds default', async () => { + test('models advertise recommendedContextWindows when long_context tier exceeds default', async () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [{ id: 'claude-sonnet', name: 'Claude Sonnet', - capabilities: { limits: { max_context_window_tokens: 200_000 } }, + capabilities: { limits: { max_context_window_tokens: 1_000_000 } }, billing: { multiplier: 1, tokenPrices: { @@ -936,16 +937,17 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const models = await waitForState(agent.models, models => models.length > 0); - const contextTier = models[0].configSchema?.properties.contextTier; - assert.deepStrictEqual(contextTier?.enum, ['default', 'long_context']); - assert.strictEqual(contextTier?.default, 'default'); - assert.deepStrictEqual(contextTier?.enumLabels, ['200K', '1M']); + // The selectable windows ride on the models list, not a numeric config property; the true + // (long) maximum stays on `maxContextWindow`. + assert.deepStrictEqual(models[0].recommendedContextWindows, [200_000, 1_000_000]); + assert.strictEqual(models[0].maxContextWindow, 1_000_000); + assert.strictEqual(models[0].configSchema?.properties.contextSize, undefined); } finally { await disposeAgent(agent); } }); - test('configSchema omits contextTier when long_context tier is missing or not larger', async () => { + test('models omit recommendedContextWindows when long_context tier is missing or not larger', async () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [ { @@ -967,8 +969,8 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const models = await waitForState(agent.models, models => models.length > 0); - assert.strictEqual(models[0].configSchema, undefined); - assert.strictEqual(models[1].configSchema, undefined); + assert.strictEqual(models[0].recommendedContextWindows, undefined); + assert.strictEqual(models[1].recommendedContextWindows, undefined); } finally { await disposeAgent(agent); } @@ -1646,6 +1648,56 @@ suite('CopilotAgent', () => { } }); + test('materialization maps the selected context window to the SDK context tier', async () => { + const longContextModel: ITestCopilotModelInfo = { + id: 'claude-sonnet', + name: 'Claude Sonnet', + capabilities: { limits: { max_context_window_tokens: 1_000_000 } }, + billing: { + multiplier: 1, + tokenPrices: { + contextMax: 200_000, + longContext: { contextMax: 1_000_000, inputPrice: 2 }, + }, + }, + }; + + const materializeWithSelectedWindow = async (selectedWindow: number | undefined): Promise => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([], [longContextModel]); + let capturedConfig: Parameters[0] | undefined; + client.createSession = async config => { + capturedConfig = config; + return new MockCopilotSession() as unknown as CopilotSession; + }; + + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await waitForState(agent.models, models => models.length > 0); + + const result = await agent.createSession({ + session: AgentSession.uri('copilotcli', `ctx-${selectedWindow ?? 'none'}`), + workingDirectory: URI.file('/workspace'), + model: { id: 'claude-sonnet', ...(selectedWindow !== undefined ? { maxContextWindow: selectedWindow } : {}) }, + }); + await agent.sendMessage(result.session, 'hello'); + return capturedConfig?.contextTier ?? undefined; + } finally { + await disposeAgent(agent); + } + }; + + assert.deepStrictEqual( + { + long: await materializeWithSelectedWindow(1_000_000), + default: await materializeWithSelectedWindow(200_000), + unset: await materializeWithSelectedWindow(undefined), + }, + { long: 'long_context', default: 'default', unset: undefined }, + ); + }); + test('materialization forwards the GitHub token to the SDK at the session level (#318693)', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const client = new TestCopilotClient([]); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts index 76739a8cdd925..ff54c4658676d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -6,11 +6,23 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { ConfigSchema, SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { formatTokenCount } from '../../../../../../base/common/numbers.js'; +import { localize } from '../../../../../../nls.js'; +import { SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { readAgentModelPricingMeta } from '../../../../../../platform/agentHost/common/agentModelPricing.js'; import { nullExtensionDescription } from '../../../../../services/extensions/common/extensions.js'; import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelConfigurationSchema } from '../../../common/languageModels.js'; +/** + * Config key under which the synthesized context-size picker is exposed. The chosen value is a token + * count carried back via `ModelSelection.maxContextWindow`; the `tokens` group marks it so the chat + * model picker renders it as the "Context Size" control. + */ +const ContextSizeConfigKey = 'contextSize'; + +type ConfigurationSchemaProperty = NonNullable[string]; + + /** * Returns whether an agent host provider exposes a synthetic "Auto" model to * fall back to. @@ -91,38 +103,75 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu toolCalling: true, agentMode: true, }, - configurationSchema: this._toLanguageModelConfigurationSchema(m.configSchema), + configurationSchema: this._toLanguageModelConfigurationSchema(m), }, }; }); } - private _toLanguageModelConfigurationSchema(schema: ConfigSchema | undefined): ILanguageModelConfigurationSchema | undefined { - if (!schema) { + /** + * Builds the language-model configuration schema for a model: the agent-provided properties (e.g. + * `thinkingLevel`) plus a synthesized numeric context-size picker derived from the model's + * {@link SessionModelInfo.recommendedContextWindows}. Keeping the context-size picker out of the + * agent-host protocol config (it lives on the models list instead) means the chosen value rides on + * the typed `ModelSelection.maxContextWindow` rather than the generic config bag. + */ + private _toLanguageModelConfigurationSchema(m: SessionModelInfo): ILanguageModelConfigurationSchema | undefined { + const properties: Record = {}; + + if (m.configSchema) { + for (const [key, property] of Object.entries(m.configSchema.properties)) { + properties[key] = { + type: property.type, + title: property.title, + description: property.description, + default: property.default, + enum: property.enum, + enumItemLabels: property.enumLabels, + enumDescriptions: property.enumDescriptions, + group: AgentHostLanguageModelProvider._groupForConfigKey(key), + }; + } + } + + const contextSize = AgentHostLanguageModelProvider._createContextSizeSchemaProperty(m.recommendedContextWindows); + if (contextSize) { + properties[ContextSizeConfigKey] = contextSize; + } + + if (Object.keys(properties).length === 0) { return undefined; } + return { type: 'object', required: m.configSchema?.required, properties }; + } + + /** + * Synthesizes the numeric "Context Size" picker property from a model's recommended context + * windows (smallest first; the first is the default). Returns `undefined` when the model offers + * fewer than two windows, so no picker is shown. + */ + private static _createContextSizeSchemaProperty(windows: readonly number[] | undefined): ConfigurationSchemaProperty | undefined { + if (!windows || windows.length < 2) { + return undefined; + } return { - type: schema.type, - required: schema.required, - properties: Object.fromEntries(Object.entries(schema.properties).map(([key, property]) => [key, { - type: property.type, - title: property.title, - description: property.description, - default: property.default, - enum: property.enum, - enumItemLabels: property.enumLabels, - enumDescriptions: property.enumDescriptions, - readOnly: property.readOnly, - group: AgentHostLanguageModelProvider._groupForConfigKey(key), - }])), + type: 'number', + title: localize('agentHost.contextSize.title', "Context Size"), + description: localize('agentHost.contextSize.description', "Selects the context window size for this model."), + default: windows[0], + enum: [...windows], + enumItemLabels: windows.map(window => formatTokenCount(window)), + enumDescriptions: windows.map((_, index) => index === 0 + ? localize('agentHost.contextSize.default', "Default") + : localize('agentHost.contextSize.longer', "Longer sessions")), + group: 'tokens', }; } private static _groupForConfigKey(key: string): string | undefined { switch (key) { case 'thinkingLevel': return 'navigation'; - case 'contextTier': return 'tokens'; default: return undefined; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index ee9e6e47bbdea..aa92cd4a71fc1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -2960,18 +2960,40 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return undefined; } + // The model's configuration schema marks its context-window property with the `tokens` + // group. That selection is a real token count, so it is carried as the typed + // `maxContextWindow` field rather than stuffed into the string `config` bag; the rest of the + // (string) configuration flows through `config` as before. + const schemaProperties = languageModelIdentifier + ? this._languageModelsService.lookupLanguageModel(languageModelIdentifier)?.configurationSchema?.properties + : undefined; + const config: Record = {}; + let maxContextWindow: number | undefined; for (const [key, value] of Object.entries(modelConfiguration ?? {})) { + if (typeof value === 'number') { + if (schemaProperties?.[key]?.group === 'tokens') { + maxContextWindow = value; + } + continue; + } if (typeof value === 'string') { config[key] = value; } } - return Object.keys(config).length > 0 ? { id: rawModelId, config } : { id: rawModelId }; + const selection: ModelSelection = { id: rawModelId }; + if (Object.keys(config).length > 0) { + selection.config = config; + } + if (maxContextWindow !== undefined) { + selection.maxContextWindow = maxContextWindow; + } + return selection; } private _modelSelectionsEqual(a: ModelSelection | undefined, b: ModelSelection | undefined): boolean { - if (a?.id !== b?.id) { + if (a?.id !== b?.id || a?.maxContextWindow !== b?.maxContextWindow) { return false; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 375b2b370cedd..b7f2724c69cf9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -1889,6 +1889,35 @@ suite('AgentHostChatContribution', () => { assert.deepStrictEqual(agentHostService.createSessionCalls[0].model, { id: 'claude-sonnet-4-20250514', config: { thinkingLevel: 'high' } }); })); + test('lifts the selected context window into maxContextWindow instead of the config bag', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const languageModels = new Map([ + ['agent-host-copilot:claude-sonnet-4-20250514', upcastPartial({ + configurationSchema: { + properties: { + thinkingLevel: { type: 'string', group: 'navigation', enum: ['low', 'high'] }, + contextSize: { type: 'number', group: 'tokens', enum: [200_000, 1_000_000] }, + }, + }, + })], + ]); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables, { languageModels }); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'Hi', + userSelectedModelId: 'agent-host-copilot:claude-sonnet-4-20250514', + modelConfiguration: { thinkingLevel: 'high', contextSize: 1_000_000 }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.createSessionCalls.length, 1); + assert.deepStrictEqual(agentHostService.createSessionCalls[0].model, { + id: 'claude-sonnet-4-20250514', + config: { thinkingLevel: 'high' }, + maxContextWindow: 1_000_000, + }); + })); + test('passes model id as-is when no vendor prefix', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); @@ -3765,11 +3794,48 @@ suite('AgentHostChatContribution', () => { enum: ['low', 'medium', 'high'], enumItemLabels: ['Low', 'Medium', 'High'], enumDescriptions: undefined, - readOnly: undefined, group: 'navigation', }); }); + test('synthesizes a tokens-group context size picker from recommendedContextWindows', async () => { + const provider = disposables.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot')); + provider.updateModels([ + { + provider: 'copilot', + id: 'claude-sonnet', + name: 'Claude Sonnet', + maxContextWindow: 1_000_000, + recommendedContextWindows: [200_000, 1_000_000], + supportsVision: false, + }, + ]); + + const models = await provider.provideLanguageModelChatInfo({}, CancellationToken.None); + + assert.deepStrictEqual(models[0].metadata.configurationSchema?.properties?.contextSize, { + type: 'number', + title: 'Context Size', + description: 'Selects the context window size for this model.', + default: 200_000, + enum: [200_000, 1_000_000], + enumItemLabels: ['200K', '1M'], + enumDescriptions: ['Default', 'Longer sessions'], + group: 'tokens', + }); + }); + + test('omits the context size picker when fewer than two recommended windows', async () => { + const provider = disposables.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot')); + provider.updateModels([ + { provider: 'copilot', id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128_000, supportsVision: false }, + ]); + + const models = await provider.provideLanguageModelChatInfo({}, CancellationToken.None); + + assert.strictEqual(models[0].metadata.configurationSchema, undefined); + }); + test('returns empty when no models set', async () => { const provider = disposables.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot')); From 62e9e15305e30ce3f40b87780dcfb911106edb8e Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Mon, 22 Jun 2026 16:20:04 -0700 Subject: [PATCH 02/18] clean --- .../platform/agentHost/common/agentService.ts | 6 -- .../agentHost/node/copilot/copilotAgent.ts | 64 ++++++++++--------- .../node/copilot/copilotSessionLauncher.ts | 48 ++++++++------ .../agentHost/test/node/copilotAgent.test.ts | 28 +++++++- 4 files changed, 91 insertions(+), 55 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 8ec4c5907a8f9..226f65da9201c 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -673,12 +673,6 @@ export interface IAgentModelInfo { readonly id: string; readonly name: string; readonly maxContextWindow?: number; - /** - * The context-window sizes (in tokens) this model recommends as user-selectable options, smallest - * first. The first entry is the recommended default. Present only when the model offers more than - * one window. Clients render these as a context-size picker and send the chosen value back via - * {@link ModelSelection.maxContextWindow}. - */ readonly recommendedContextWindows?: readonly number[]; readonly supportsVision: boolean; readonly configSchema?: ConfigSchema; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 05cf2cdbf0612..6d8e515df0192 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -51,7 +51,7 @@ import { ICopilotBranchNameGenerator } from './copilotBranchNameGenerator.js'; import { CopilotAgentSession, type CopilotSdkMode } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; -import { CopilotSessionLauncher, ThinkingLevelConfigKey, getCopilotReasoningEffort, mapContextSizeToContextTier, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +import { CopilotSessionLauncher, ContextTierConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, mapContextSizeToContextTier, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { ShellManager } from './copilotShellTools.js'; import { isRestrictedTelemetryEnabled } from './copilotTokenFields.js'; import { CopilotSlashCommandCompletionProvider } from './copilotSlashCommandCompletionProvider.js'; @@ -126,7 +126,6 @@ type ModelInfo = Awaited>['mo interface ISerializedModelSelection { id?: unknown; config?: unknown; - maxContextWindow?: unknown; } /** @@ -720,27 +719,36 @@ export class CopilotAgent extends Disposable implements IAgent { } /** - * Maps a model selection's chosen {@link ModelSelection.maxContextWindow} (a token count) to the - * SDK's `contextTier`. The value must be one of the model's - * {@link IAgentModelInfo.recommendedContextWindows}; the smallest recommended window is the default - * tier and anything larger opts into `long_context`. A value the model does not recommend is - * rejected (ignored, leaving the SDK on its default tier), so a stale or out-of-range client - * selection cannot silently request an unsupported window. + * Normalizes a model selection received from a client by resolving its numeric + * {@link ModelSelection.maxContextWindow} into the SDK context tier and folding it into + * {@link ModelSelection.config} (under `contextTier`), then dropping the numeric field. After this, + * the tier travels with the model alongside `reasoningEffort` and is persisted with the session, so + * the launcher and the resume path read it directly via `getCopilotContextTier` without needing the + * model list again. + * + * The chosen window must be one of the model's {@link IAgentModelInfo.recommendedContextWindows}; + * reaching the largest recommended window selects `long_context`, otherwise the default tier. A + * value the model does not recommend is rejected (no tier is set), so a stale or out-of-range client + * selection cannot silently request an unsupported window. Resolving here — when the selection + * arrives and the model list is freshly loaded — is more reliable than at launch/resume time. */ - private _resolveContextTier(model: ModelSelection | undefined): ReturnType { - const selectedWindow = model?.maxContextWindow; - if (typeof selectedWindow !== 'number' || !model) { - return undefined; + private _resolveModelSelection(model: ModelSelection | undefined): ModelSelection | undefined { + if (!model || typeof model.maxContextWindow !== 'number') { + return model; } + const { maxContextWindow, ...rest } = model; const windows = this._models.get().find(m => m.id === model.id)?.recommendedContextWindows; - if (!windows || windows.length === 0) { - return undefined; + if (!windows || windows.length === 0 || !windows.includes(maxContextWindow)) { + if (windows && windows.length > 0) { + this._logService.warn(`[Copilot] Ignoring unsupported context window ${maxContextWindow} for model '${model.id}'; expected one of [${windows.join(', ')}]`); + } + return rest; } - if (!windows.includes(selectedWindow)) { - this._logService.warn(`[Copilot] Ignoring unsupported context window ${selectedWindow} for model '${model.id}'; expected one of [${windows.join(', ')}]`); - return undefined; + const contextTier = mapContextSizeToContextTier(maxContextWindow, Math.max(...windows)); + if (!contextTier) { + return rest; } - return mapContextSizeToContextTier(selectedWindow, Math.min(...windows)); + return { ...rest, config: { ...rest.config, [ContextTierConfigKey]: contextTier } }; } /** @@ -811,9 +819,6 @@ export class CopilotAgent extends Disposable implements IAgent { modelSelection.config = config; } } - if (typeof value.maxContextWindow === 'number') { - modelSelection.maxContextWindow = value.maxContextWindow; - } return modelSelection; } } catch { @@ -971,7 +976,7 @@ export class CopilotAgent extends Disposable implements IAgent { } async createSession(config?: IAgentCreateSessionConfig): Promise { - const sessionConfig = config ?? {}; + const sessionConfig = { ...(config ?? {}), model: this._resolveModelSelection(config?.model) }; this._logService.info(`[Copilot] Creating session... ${sessionConfig.model ? `model=${sessionConfig.model.id}` : ''}`); const sessionId = sessionConfig.session ? AgentSession.id(sessionConfig.session) : generateUuid(); @@ -1165,7 +1170,6 @@ export class CopilotAgent extends Disposable implements IAgent { shellManager, githubToken: this._githubToken, model: provisional.model, - contextTier: this._resolveContextTier(provisional.model), }; agentSession = this._createAgentSession(launchPlan, customizationDirectory, activeClient); await agentSession.initializeSession(); @@ -1627,6 +1631,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (this._chatSessions.has(chatKey)) { return; } + const model = this._resolveModelSelection(options?.model); // Resolve the owning session so the new chat inherits its working // directory scope. The parent may be provisional (no SDK session // yet); in that case use its provisional working directory. @@ -1655,8 +1660,7 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientState: activeClient.state, shellManager, githubToken: this._githubToken, - model: options?.model, - contextTier: this._resolveContextTier(options?.model), + model, }; let agentSession: CopilotAgentSession | undefined; try { @@ -1666,7 +1670,7 @@ export class CopilotAgent extends Disposable implements IAgent { const parsed = parseChatUri(chat); if (parsed) { const persisted = await this._readPersistedChats(session); - persisted.set(parsed.chatId, { sdkSessionId: chatSdkId, ...(options?.model ? { model: options.model } : {}) }); + persisted.set(parsed.chatId, { sdkSessionId: chatSdkId, ...(model ? { model } : {}) }); await this._writePersistedChats(session, persisted); } this._logService.info(`[Copilot] Created additional chat ${chatKey} in session ${session.toString()}`); @@ -1770,7 +1774,7 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientState: activeClient.state, shellManager, githubToken: this._githubToken, - fallback: { model: info.model, contextTier: this._resolveContextTier(info.model) }, + fallback: { model: info.model }, }; let agentSession: CopilotAgentSession | undefined; try { @@ -1821,12 +1825,13 @@ export class CopilotAgent extends Disposable implements IAgent { } async changeModel(session: URI, model: ModelSelection, chat?: URI): Promise { + model = this._resolveModelSelection(model) ?? model; // Additional (non-default) chats are backed by their own SDK // conversation tracked in `_chatSessions`; apply the change there and // skip the session-level metadata store (peer chats are not persisted // per-chat). if (chat && !isDefaultChatUri(chat)) { - await this._chatSessions.get(chat.toString())?.setModel(model.id, getCopilotReasoningEffort(model), this._resolveContextTier(model)); + await this._chatSessions.get(chat.toString())?.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model)); return; } const sessionId = AgentSession.id(session); @@ -1837,7 +1842,7 @@ export class CopilotAgent extends Disposable implements IAgent { } const entry = this._sessions.get(sessionId); if (entry) { - await entry.setModel(model.id, getCopilotReasoningEffort(model), this._resolveContextTier(model)); + await entry.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model)); } await this._storeSessionMetadata(session, model, undefined, undefined, undefined); } @@ -2085,7 +2090,6 @@ export class CopilotAgent extends Disposable implements IAgent { githubToken: this._githubToken, fallback: { model: storedMetadata.model, - contextTier: this._resolveContextTier(storedMetadata.model), }, }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 2a5425a91c8e3..bc217b0b1e71f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -26,10 +26,14 @@ import { agentHostPromptRegistry, type IAgentHostPromptContext } from './prompts import './prompts/allPrompts.js'; export const ThinkingLevelConfigKey = 'thinkingLevel'; +export const ContextTierConfigKey = 'contextTier'; const ReasoningEfforts = ['low', 'medium', 'high', 'xhigh'] as const; type ReasoningEffort = NonNullable; +const ContextTiers = ['default', 'long_context'] as const; +type ContextTier = NonNullable; + type UserInputHandler = NonNullable; type UserInputRequest = Parameters[0]; type UserInputInvocation = Parameters[1]; @@ -104,12 +108,6 @@ interface ICopilotSessionLaunchBase { export interface ICopilotCreateSessionLaunchPlan extends ICopilotSessionLaunchBase { readonly kind: 'create'; readonly model: ModelSelection | undefined; - /** - * The SDK context tier resolved from the model selection's numeric `contextSize`. Resolved by - * the agent (which owns the model list / window sizes) rather than recomputed here, since the - * number→tier mapping needs the model's `maxContextWindow`. - */ - readonly contextTier?: SessionConfig['contextTier']; } export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBase { @@ -117,17 +115,19 @@ export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBa readonly workingDirectory: URI; readonly fallback: { readonly model: ModelSelection | undefined; - /** Resolved SDK context tier for {@link fallback.model}; see {@link ICopilotCreateSessionLaunchPlan.contextTier}. */ - readonly contextTier?: SessionConfig['contextTier']; }; } export type CopilotSessionLaunchPlan = ICopilotCreateSessionLaunchPlan | ICopilotResumeSessionLaunchPlan; -function isReasoningEffort(value: string | number | undefined): value is ReasoningEffort { +function isReasoningEffort(value: string | undefined): value is ReasoningEffort { return ReasoningEfforts.some(reasoningEffort => reasoningEffort === value); } +function isContextTier(value: string | undefined): value is ContextTier { + return ContextTiers.some(contextTier => contextTier === value); +} + function getCopilotSdkErrorCode(err: unknown): number | undefined { if (typeof err !== 'object' || err === null) { return undefined; @@ -177,20 +177,33 @@ export function getCopilotReasoningEffort(model: ModelSelection | undefined): Se return isReasoningEffort(thinkingLevel) ? thinkingLevel : undefined; } +/** + * Reads the SDK context tier from a model selection's `config`. The agent resolves the user's numeric + * context-window choice into this string config value when the selection is received (see + * `mapContextSizeToContextTier`), so by launch time the tier travels alongside `reasoningEffort` in + * `config` and is persisted with the session. + */ +export function getCopilotContextTier(model: ModelSelection | undefined): SessionConfig['contextTier'] { + const contextTier = model?.config?.[ContextTierConfigKey]; + return isContextTier(contextTier) ? contextTier : undefined; +} + /** * Maps a user-selected context-window size (in tokens) to the SDK's two-valued - * {@link SessionConfig.contextTier}. The model offers exactly two windows — the default-tier window - * and the larger long-context window — so any selection above the default-tier window opts into - * `long_context`. + * {@link SessionConfig.contextTier}. The model offers two windows — the default-tier window and the + * larger long-context window. A selection only opts into `long_context` when it reaches the model's + * long-context window: anything smaller (including a value nudged just above the default window) + * stays on the default tier, so a client cannot accidentally request long context by rounding a + * number up. * - * Returns `undefined` when no size was selected or the default-tier window is unknown, leaving the + * Returns `undefined` when no size was selected or the long-context window is unknown, leaving the * SDK on its default tier. */ -export function mapContextSizeToContextTier(selectedWindow: number | undefined, defaultContextWindow: number | undefined): SessionConfig['contextTier'] { - if (typeof selectedWindow !== 'number' || typeof defaultContextWindow !== 'number') { +export function mapContextSizeToContextTier(selectedWindow: number | undefined, longContextWindow: number | undefined): SessionConfig['contextTier'] { + if (typeof selectedWindow !== 'number' || typeof longContextWindow !== 'number') { return undefined; } - return selectedWindow > defaultContextWindow ? 'long_context' : 'default'; + return selectedWindow >= longContextWindow ? 'long_context' : 'default'; } export class CopilotSessionLauncher implements ICopilotSessionLauncher { @@ -235,7 +248,6 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { ...plan, kind: 'create', model: plan.fallback.model, - contextTier: plan.fallback.contextTier, }, config, sandboxConfig); this._logService.info(`[Copilot:${plan.sessionId}] Fallback createSession succeeded`); return wrapper; @@ -249,7 +261,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { streaming: true, model: plan.model?.id, reasoningEffort: getCopilotReasoningEffort(plan.model), - contextTier: plan.contextTier, + contextTier: getCopilotContextTier(plan.model), ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), workingDirectory: plan.workingDirectory?.fsPath, }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index f5528f417811d..b1aa5fe84ebf9 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -46,7 +46,7 @@ import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, getCopilotWorktreeName import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; import { CopilotBranchNameGenerator, ICopilotBranchNameGenerator, getCopilotBranchNameHintFromMessage, normalizeCopilotBranchName } from '../../node/copilot/copilotBranchNameGenerator.js'; -import type { CopilotSessionLaunchPlan, IActiveClientSnapshot } from '../../node/copilot/copilotSessionLauncher.js'; +import { mapContextSizeToContextTier, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from '../../node/copilot/copilotSessionLauncher.js'; import { ShellManager } from '../../node/copilot/copilotShellTools.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; @@ -976,6 +976,32 @@ suite('CopilotAgent', () => { } }); + test('mapContextSizeToContextTier only selects long context when the long window is reached', () => { + const defaultWindow = 200_000; + const longWindow = 1_000_000; + assert.deepStrictEqual( + { + unset: mapContextSizeToContextTier(undefined, longWindow), + noWindow: mapContextSizeToContextTier(longWindow, undefined), + exactDefault: mapContextSizeToContextTier(defaultWindow, longWindow), + // A value nudged just above the default must NOT round up into long context. + roundedUpDefault: mapContextSizeToContextTier(defaultWindow + 1, longWindow), + justBelowLong: mapContextSizeToContextTier(longWindow - 1, longWindow), + exactLong: mapContextSizeToContextTier(longWindow, longWindow), + aboveLong: mapContextSizeToContextTier(longWindow + 1, longWindow), + }, + { + unset: undefined, + noWindow: undefined, + exactDefault: 'default', + roundedUpDefault: 'default', + justBelowLong: 'default', + exactLong: 'long_context', + aboveLong: 'long_context', + }, + ); + }); + test('agent-created sessions can resolve session-state paths via INativeEnvironmentService', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const { agent, instantiationService } = createTestAgentContext(disposables, { From 03d0a03b98b7f7868d1d5d2536ac7bd1e4ba83ff Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Mon, 22 Jun 2026 16:21:19 -0700 Subject: [PATCH 03/18] clean --- src/vs/platform/agentHost/node/copilot/copilotAgent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 6d8e515df0192..f9fb6214540e7 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -702,7 +702,7 @@ export class CopilotAgent extends Disposable implements IAgent { * tier: `[defaultMax, longContextMax]`, smallest first. Returns `undefined` when the model has no * such distinction (so no picker is offered). Surfaced on {@link IAgentModelInfo.recommendedContextWindows}; * the chosen value flows back via {@link ModelSelection.maxContextWindow} and is mapped to the SDK's - * `contextTier` by {@link _resolveContextTier}. Mirrors `getContextSizeOptions` in + * `contextTier` by {@link _resolveModelSelection}. Mirrors `getContextSizeOptions` in * `extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts`. * * `billing.tokenPrices` is present on the runtime CAPI `/models` payload but not yet declared on the published SDK From 8e04c6585157e6c72c421b18c8ccd5fdf588ff55 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Mon, 22 Jun 2026 16:42:04 -0700 Subject: [PATCH 04/18] clean --- .../state/protocol/channels-root/state.ts | 6 +- .../agentHost/node/copilot/copilotAgent.ts | 62 +++++++++++++------ 2 files changed, 45 insertions(+), 23 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index fdddd81e962ff..7534d8186f34a 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -99,9 +99,9 @@ export interface SessionModelInfo { maxContextWindow?: number; /** * Context-window sizes (in tokens) this model recommends as user-selectable options, smallest - * first. The first entry is the recommended default. Present only when the model offers more than - * one window. Clients render these as a context-size picker and send the chosen value back via - * {@link ModelSelection.maxContextWindow}. + * first. The first entry is the recommended default. Present only when the model offers meaningful + * context-window options (e.g., long context pricing). Clients can optionally render these as a + * context-size picker and send the chosen value back via {@link ModelSelection.maxContextWindow}. */ recommendedContextWindows?: readonly number[]; /** Whether the model supports vision */ diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index f9fb6214540e7..acd2d708f1fde 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -112,7 +112,7 @@ interface IProvisionalSession { */ readonly workingDirectory: URI; /** Most recent model selection. Updated by `changeModel` while provisional. */ - model: ModelSelection | undefined; + model: ResolvedModelSelection | undefined; /** Most recent custom agent selection. Updated by `changeAgent` while provisional. */ agent: AgentSelection | undefined; /** Project info eagerly resolved at create time so the summary renders. */ @@ -123,6 +123,16 @@ export { COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from './prompts/systemMessage.js'; type ModelInfo = Awaited>['models'][number]; +declare const resolvedModelSelectionBrand: unique symbol; +/** + * A {@link ModelSelection} that has passed through {@link CopilotAgent._resolveModelSelection}: its + * numeric `maxContextWindow` has been resolved into `config.contextTier` (and dropped). The brand is + * compiler-enforced — every field that stores, persists, or launches a model requires it — so a raw + * client selection cannot be used without first being resolved, removing the risk of forgetting the + * conversion at a new call site. + */ +type ResolvedModelSelection = Omit & { readonly [resolvedModelSelectionBrand]: true }; + interface ISerializedModelSelection { id?: unknown; config?: unknown; @@ -135,7 +145,7 @@ interface ISerializedModelSelection { */ interface IPersistedChat { readonly sdkSessionId: string; - readonly model?: ModelSelection; + readonly model?: ResolvedModelSelection; } /** @@ -731,24 +741,33 @@ export class CopilotAgent extends Disposable implements IAgent { * value the model does not recommend is rejected (no tier is set), so a stale or out-of-range client * selection cannot silently request an unsupported window. Resolving here — when the selection * arrives and the model list is freshly loaded — is more reliable than at launch/resume time. + * + * The branded {@link ResolvedModelSelection} return type makes this the only producer of a storable + * model, so the compiler rejects persisting or launching a raw client selection that skipped it. */ - private _resolveModelSelection(model: ModelSelection | undefined): ModelSelection | undefined { - if (!model || typeof model.maxContextWindow !== 'number') { - return model; + private _resolveModelSelection(model: ModelSelection): ResolvedModelSelection; + private _resolveModelSelection(model: ModelSelection | undefined): ResolvedModelSelection | undefined; + private _resolveModelSelection(model: ModelSelection | undefined): ResolvedModelSelection | undefined { + if (!model) { + return undefined; } const { maxContextWindow, ...rest } = model; + if (typeof maxContextWindow !== 'number') { + return rest as ResolvedModelSelection; + } const windows = this._models.get().find(m => m.id === model.id)?.recommendedContextWindows; if (!windows || windows.length === 0 || !windows.includes(maxContextWindow)) { if (windows && windows.length > 0) { this._logService.warn(`[Copilot] Ignoring unsupported context window ${maxContextWindow} for model '${model.id}'; expected one of [${windows.join(', ')}]`); } - return rest; + return rest as ResolvedModelSelection; } const contextTier = mapContextSizeToContextTier(maxContextWindow, Math.max(...windows)); if (!contextTier) { - return rest; + return rest as ResolvedModelSelection; } - return { ...rest, config: { ...rest.config, [ContextTierConfigKey]: contextTier } }; + const resolved: ModelSelection = { ...rest, config: { ...rest.config, [ContextTierConfigKey]: contextTier } }; + return resolved as ResolvedModelSelection; } /** @@ -799,7 +818,7 @@ export class CopilotAgent extends Disposable implements IAgent { return JSON.stringify(model); } - private _parseModelSelection(raw: string | undefined): ModelSelection | undefined { + private _parseModelSelection(raw: string | undefined): ResolvedModelSelection | undefined { if (!raw) { return undefined; } @@ -819,13 +838,16 @@ export class CopilotAgent extends Disposable implements IAgent { modelSelection.config = config; } } - return modelSelection; + // Persisted selections were already resolved (and stripped of `maxContextWindow`) before + // they were written, so the parsed result is a resolved model. + return modelSelection as ResolvedModelSelection; } } catch { // Older session metadata stored the raw model id as a plain string. } - return { id: raw }; + const fallback: ModelSelection = { id: raw }; + return fallback as ResolvedModelSelection; } private _serializeAgentSelection(agent: AgentSelection): string { @@ -1825,26 +1847,26 @@ export class CopilotAgent extends Disposable implements IAgent { } async changeModel(session: URI, model: ModelSelection, chat?: URI): Promise { - model = this._resolveModelSelection(model) ?? model; + const resolved = this._resolveModelSelection(model); // Additional (non-default) chats are backed by their own SDK // conversation tracked in `_chatSessions`; apply the change there and // skip the session-level metadata store (peer chats are not persisted // per-chat). if (chat && !isDefaultChatUri(chat)) { - await this._chatSessions.get(chat.toString())?.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model)); + await this._chatSessions.get(chat.toString())?.setModel(resolved.id, getCopilotReasoningEffort(resolved), getCopilotContextTier(resolved)); return; } const sessionId = AgentSession.id(session); const provisional = this._provisionalSessions.get(sessionId); if (provisional) { - provisional.model = model; + provisional.model = resolved; return; } const entry = this._sessions.get(sessionId); if (entry) { - await entry.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model)); + await entry.setModel(resolved.id, getCopilotReasoningEffort(resolved), getCopilotContextTier(resolved)); } - await this._storeSessionMetadata(session, model, undefined, undefined, undefined); + await this._storeSessionMetadata(session, resolved, undefined, undefined, undefined); } async changeAgent(session: URI, agent: AgentSelection | undefined, chat?: URI): Promise { @@ -2209,7 +2231,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (typeof sdkSessionId !== 'string' || !sdkSessionId) { continue; } - result.set(chatId, { sdkSessionId, ...(model ? { model: model as ModelSelection } : {}) }); + result.set(chatId, { sdkSessionId, ...(model ? { model: model as ResolvedModelSelection } : {}) }); } return result; } catch (err) { @@ -2277,7 +2299,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _storeSessionMetadata(session: URI, model: ModelSelection | undefined, workingDirectory: URI | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): Promise { + private async _storeSessionMetadata(session: URI, model: ResolvedModelSelection | undefined, workingDirectory: URI | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): Promise { const dbRef = this._sessionDataService.openDatabase(session); const db = dbRef.object; try { @@ -2304,7 +2326,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI }> { + private async _readSessionMetadata(session: URI): Promise<{ model?: ResolvedModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI }> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return {}; @@ -2327,7 +2349,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _readStoredSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean } | undefined> { + private async _readStoredSessionMetadata(session: URI): Promise<{ model?: ResolvedModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean } | undefined> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return undefined; From 8691964455c34c145449488ad35492f2c424caf6 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Mon, 22 Jun 2026 16:54:46 -0700 Subject: [PATCH 05/18] clean --- .../agentHost/node/copilot/copilotAgent.ts | 22 +++++++++++++++-- .../node/copilot/copilotSessionLauncher.ts | 24 ------------------- .../agentHost/test/node/copilotAgent.test.ts | 4 ++-- 3 files changed, 22 insertions(+), 28 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index acd2d708f1fde..6db8f5a0f5aae 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CopilotClient, RuntimeConnection, type CopilotClientOptions } from '@github/copilot-sdk'; +import { CopilotClient, RuntimeConnection, type CopilotClientOptions, type SessionConfig } from '@github/copilot-sdk'; import * as fs from 'fs/promises'; import * as os from 'os'; import { CancelablePromise, createCancelablePromise, Delayer, Limiter, SequencerByKey } from '../../../../base/common/async.js'; @@ -51,7 +51,7 @@ import { ICopilotBranchNameGenerator } from './copilotBranchNameGenerator.js'; import { CopilotAgentSession, type CopilotSdkMode } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; -import { CopilotSessionLauncher, ContextTierConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, mapContextSizeToContextTier, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +import { CopilotSessionLauncher, ContextTierConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { ShellManager } from './copilotShellTools.js'; import { isRestrictedTelemetryEnabled } from './copilotTokenFields.js'; import { CopilotSlashCommandCompletionProvider } from './copilotSlashCommandCompletionProvider.js'; @@ -133,6 +133,24 @@ declare const resolvedModelSelectionBrand: unique symbol; */ type ResolvedModelSelection = Omit & { readonly [resolvedModelSelectionBrand]: true }; +/** + * Maps a user-selected context-window size (in tokens) to the SDK's two-valued + * {@link SessionConfig.contextTier}. The model offers two windows — the default-tier window and the + * larger long-context window. A selection only opts into `long_context` when it reaches the model's + * long-context window: anything smaller (including a value nudged just above the default window) + * stays on the default tier, so a client cannot accidentally request long context by rounding a + * number up. + * + * Returns `undefined` when no size was selected or the long-context window is unknown, leaving the + * SDK on its default tier. + */ +export function mapContextSizeToContextTier(selectedWindow: number | undefined, longContextWindow: number | undefined): SessionConfig['contextTier'] { + if (typeof selectedWindow !== 'number' || typeof longContextWindow !== 'number') { + return undefined; + } + return selectedWindow >= longContextWindow ? 'long_context' : 'default'; +} + interface ISerializedModelSelection { id?: unknown; config?: unknown; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index bc217b0b1e71f..97470ca6b711d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -177,35 +177,11 @@ export function getCopilotReasoningEffort(model: ModelSelection | undefined): Se return isReasoningEffort(thinkingLevel) ? thinkingLevel : undefined; } -/** - * Reads the SDK context tier from a model selection's `config`. The agent resolves the user's numeric - * context-window choice into this string config value when the selection is received (see - * `mapContextSizeToContextTier`), so by launch time the tier travels alongside `reasoningEffort` in - * `config` and is persisted with the session. - */ export function getCopilotContextTier(model: ModelSelection | undefined): SessionConfig['contextTier'] { const contextTier = model?.config?.[ContextTierConfigKey]; return isContextTier(contextTier) ? contextTier : undefined; } -/** - * Maps a user-selected context-window size (in tokens) to the SDK's two-valued - * {@link SessionConfig.contextTier}. The model offers two windows — the default-tier window and the - * larger long-context window. A selection only opts into `long_context` when it reaches the model's - * long-context window: anything smaller (including a value nudged just above the default window) - * stays on the default tier, so a client cannot accidentally request long context by rounding a - * number up. - * - * Returns `undefined` when no size was selected or the long-context window is unknown, leaving the - * SDK on its default tier. - */ -export function mapContextSizeToContextTier(selectedWindow: number | undefined, longContextWindow: number | undefined): SessionConfig['contextTier'] { - if (typeof selectedWindow !== 'number' || typeof longContextWindow !== 'number') { - return undefined; - } - return selectedWindow >= longContextWindow ? 'long_context' : 'default'; -} - export class CopilotSessionLauncher implements ICopilotSessionLauncher { constructor( diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index b1aa5fe84ebf9..9af3f066047e0 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -42,11 +42,11 @@ import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { AgentHostCompletions, IAgentHostCompletions } from '../../node/agentHostCompletions.js'; -import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, getCopilotWorktreeName, getCopilotWorktreesRoot } from '../../node/copilot/copilotAgent.js'; +import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, getCopilotWorktreeName, getCopilotWorktreesRoot, mapContextSizeToContextTier } from '../../node/copilot/copilotAgent.js'; import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; import { CopilotBranchNameGenerator, ICopilotBranchNameGenerator, getCopilotBranchNameHintFromMessage, normalizeCopilotBranchName } from '../../node/copilot/copilotBranchNameGenerator.js'; -import { mapContextSizeToContextTier, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from '../../node/copilot/copilotSessionLauncher.js'; +import { type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from '../../node/copilot/copilotSessionLauncher.js'; import { ShellManager } from '../../node/copilot/copilotShellTools.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; From c6e35553b98adfe0cc0ee27afbeb4c1ada4fe810 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 23 Jun 2026 11:52:34 -0700 Subject: [PATCH 06/18] reverrts --- .../platform/agentHost/common/agentService.ts | 1 - .../state/protocol/channels-root/state.ts | 14 -- .../common/state/protocol/common/state.ts | 4 +- .../agentHost/node/agentSideEffects.ts | 1 - .../agentHost/node/copilot/copilotAgent.ts | 164 ++++++++--------- .../node/copilot/copilotSessionLauncher.ts | 2 +- .../test/node/agentSideEffects.test.ts | 1 - .../agentHost/test/node/copilotAgent.test.ts | 165 ++++++++---------- .../agentHost/browser/agentHostModePicker.ts | 2 +- .../agentHostPermissionPickerDelegate.ts | 2 +- .../browser/agentHostSessionConfigPicker.ts | 4 +- .../mobile/mobileChatInputConfigPicker.ts | 4 +- .../mobile/mobileChatPhoneInputPresenter.ts | 4 +- .../agentHost/agentHostChatInputPicker.ts | 6 +- .../agentHostLanguageModelProvider.ts | 85 ++------- .../agentHost/agentHostSessionHandler.ts | 26 +-- .../agentHostChatContribution.test.ts | 68 +------- 17 files changed, 185 insertions(+), 368 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 226f65da9201c..3de8693ee6d39 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -673,7 +673,6 @@ export interface IAgentModelInfo { readonly id: string; readonly name: string; readonly maxContextWindow?: number; - readonly recommendedContextWindows?: readonly number[]; readonly supportsVision: boolean; readonly configSchema?: ConfigSchema; readonly policyState?: PolicyState; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index 7534d8186f34a..e9d897bde9ba3 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -97,13 +97,6 @@ export interface SessionModelInfo { name: string; /** Maximum context window size */ maxContextWindow?: number; - /** - * Context-window sizes (in tokens) this model recommends as user-selectable options, smallest - * first. The first entry is the recommended default. Present only when the model offers meaningful - * context-window options (e.g., long context pricing). Clients can optionally render these as a - * context-size picker and send the chosen value back via {@link ModelSelection.maxContextWindow}. - */ - recommendedContextWindows?: readonly number[]; /** Whether the model supports vision */ supportsVision?: boolean; /** Policy configuration state */ @@ -135,13 +128,6 @@ export interface ModelSelection { id: string; /** Model-specific configuration values */ config?: Record; - /** - * The context-window size (in tokens) the user selected for this model, chosen from the - * windows the model offers (see {@link SessionModelInfo.maxContextWindow} for the model's true - * maximum). The agent maps this to whatever backend representation it uses (e.g. a context - * tier). Absent when the model exposes no selectable windows or the user kept the default. - */ - maxContextWindow?: number; } // ─── Root Config Types ─────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/common/state.ts b/src/vs/platform/agentHost/common/state/protocol/common/state.ts index d71968904d5ee..5a2e4939cce83 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/state.ts @@ -161,8 +161,8 @@ export interface ConfigPropertySchema { description?: string; /** JSON Schema: default value */ default?: unknown; - /** JSON Schema: allowed values (typically used with `string` type) */ - enum?: string[]; + /** JSON Schema: allowed values. May be primitives of any JSON type (e.g. numeric token counts). */ + enum?: (string | number | boolean | null)[]; /** Display extension: human-readable label per enum value (parallel array) */ enumLabels?: string[]; /** Display extension: description per enum value (parallel array) */ diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 4fc93ea0349dc..0cb3f7fa50f59 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -176,7 +176,6 @@ export class AgentSideEffects extends Disposable { provider: m.provider, name: m.name, maxContextWindow: m.maxContextWindow, - recommendedContextWindows: m.recommendedContextWindows, supportsVision: m.supportsVision, policyState: m.policyState, configSchema: m.configSchema, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 6db8f5a0f5aae..cbcdefb4b04ba 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CopilotClient, RuntimeConnection, type CopilotClientOptions, type SessionConfig } from '@github/copilot-sdk'; +import { CopilotClient, RuntimeConnection, type CopilotClientOptions } from '@github/copilot-sdk'; import * as fs from 'fs/promises'; import * as os from 'os'; import { CancelablePromise, createCancelablePromise, Delayer, Limiter, SequencerByKey } from '../../../../base/common/async.js'; @@ -14,6 +14,7 @@ import { appendEscapedMarkdownInlineCode } from '../../../../base/common/htmlCon import { Disposable, DisposableMap, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { FileAccess } from '../../../../base/common/network.js'; +import { formatTokenCount } from '../../../../base/common/numbers.js'; import { equals } from '../../../../base/common/objects.js'; import { observableValue } from '../../../../base/common/observable.js'; import { basename, delimiter, dirname, join } from '../../../../base/common/path.js'; @@ -51,7 +52,7 @@ import { ICopilotBranchNameGenerator } from './copilotBranchNameGenerator.js'; import { CopilotAgentSession, type CopilotSdkMode } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; -import { CopilotSessionLauncher, ContextTierConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +import { CopilotSessionLauncher, ContextTierConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, isContextTier, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { ShellManager } from './copilotShellTools.js'; import { isRestrictedTelemetryEnabled } from './copilotTokenFields.js'; import { CopilotSlashCommandCompletionProvider } from './copilotSlashCommandCompletionProvider.js'; @@ -112,7 +113,7 @@ interface IProvisionalSession { */ readonly workingDirectory: URI; /** Most recent model selection. Updated by `changeModel` while provisional. */ - model: ResolvedModelSelection | undefined; + model: ModelSelection | undefined; /** Most recent custom agent selection. Updated by `changeAgent` while provisional. */ agent: AgentSelection | undefined; /** Project info eagerly resolved at create time so the summary renders. */ @@ -123,34 +124,6 @@ export { COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from './prompts/systemMessage.js'; type ModelInfo = Awaited>['models'][number]; -declare const resolvedModelSelectionBrand: unique symbol; -/** - * A {@link ModelSelection} that has passed through {@link CopilotAgent._resolveModelSelection}: its - * numeric `maxContextWindow` has been resolved into `config.contextTier` (and dropped). The brand is - * compiler-enforced — every field that stores, persists, or launches a model requires it — so a raw - * client selection cannot be used without first being resolved, removing the risk of forgetting the - * conversion at a new call site. - */ -type ResolvedModelSelection = Omit & { readonly [resolvedModelSelectionBrand]: true }; - -/** - * Maps a user-selected context-window size (in tokens) to the SDK's two-valued - * {@link SessionConfig.contextTier}. The model offers two windows — the default-tier window and the - * larger long-context window. A selection only opts into `long_context` when it reaches the model's - * long-context window: anything smaller (including a value nudged just above the default window) - * stays on the default tier, so a client cannot accidentally request long context by rounding a - * number up. - * - * Returns `undefined` when no size was selected or the long-context window is unknown, leaving the - * SDK on its default tier. - */ -export function mapContextSizeToContextTier(selectedWindow: number | undefined, longContextWindow: number | undefined): SessionConfig['contextTier'] { - if (typeof selectedWindow !== 'number' || typeof longContextWindow !== 'number') { - return undefined; - } - return selectedWindow >= longContextWindow ? 'long_context' : 'default'; -} - interface ISerializedModelSelection { id?: unknown; config?: unknown; @@ -163,7 +136,7 @@ interface ISerializedModelSelection { */ interface IPersistedChat { readonly sdkSessionId: string; - readonly model?: ResolvedModelSelection; + readonly model?: ModelSelection; } /** @@ -725,67 +698,76 @@ export class CopilotAgent extends Disposable implements IAgent { } /** - * Computes the context-window sizes (in tokens) a model recommends as user-selectable options when - * it exposes a `long_context` pricing tier whose context-max is strictly larger than its default - * tier: `[defaultMax, longContextMax]`, smallest first. Returns `undefined` when the model has no - * such distinction (so no picker is offered). Surfaced on {@link IAgentModelInfo.recommendedContextWindows}; - * the chosen value flows back via {@link ModelSelection.maxContextWindow} and is mapped to the SDK's - * `contextTier` by {@link _resolveModelSelection}. Mirrors `getContextSizeOptions` in + * Synthesize a `contextTier` config property when the model exposes a `long_context` pricing tier with a distinct + * context-max. Picker surfaces this as the "Context Size" button. Mirrors `getContextSizeOptions` in * `extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts`. * + * The `enum` values are the two context-window sizes (in tokens), smallest first, so the numeric token counts + * flow to the client; the chosen value is mapped back to the SDK's two-valued `contextTier` by + * {@link _resolveContextTier} when the selection returns. + * * `billing.tokenPrices` is present on the runtime CAPI `/models` payload but not yet declared on the published SDK * `ModelBilling` type — narrow through {@link ICopilotModelBilling} until the SDK catches up. */ - private _getRecommendedContextWindows(billing: ModelInfo['billing'] | undefined): number[] | undefined { + private _createContextTierConfigSchemaProperty(billing: ModelInfo['billing'] | undefined): ConfigPropertySchema | undefined { const tokenPrices = billing?.tokenPrices; const defaultMax = tokenPrices?.contextMax; const longContextMax = tokenPrices?.longContext?.contextMax; if (!defaultMax || !longContextMax || defaultMax >= longContextMax) { return undefined; } - return [defaultMax, longContextMax]; + + const hasLongContextSurcharge = typeof tokenPrices?.longContext?.inputPrice === 'number' + || typeof tokenPrices?.longContext?.outputPrice === 'number'; + + return { + type: 'number', + title: localize('copilot.modelContextTier.title', "Context Size"), + description: localize('copilot.modelContextTier.description', "Selects the context window size for this model."), + default: defaultMax, + enum: [defaultMax, longContextMax], + enumLabels: [formatTokenCount(defaultMax), formatTokenCount(longContextMax)], + enumDescriptions: [ + localize('copilot.modelContextTier.default', "Default"), + hasLongContextSurcharge + ? localize('copilot.modelContextTier.longerSessions', "Longer sessions") + : localize('copilot.modelContextTier.longerSessionsNoCompaction', "Longer sessions without compaction"), + ], + }; } /** - * Normalizes a model selection received from a client by resolving its numeric - * {@link ModelSelection.maxContextWindow} into the SDK context tier and folding it into - * {@link ModelSelection.config} (under `contextTier`), then dropping the numeric field. After this, - * the tier travels with the model alongside `reasoningEffort` and is persisted with the session, so - * the launcher and the resume path read it directly via `getCopilotContextTier` without needing the - * model list again. + * Maps a model selection received from a client back onto the SDK's two-valued `contextTier`. The + * "Context Size" picker exposes numeric token-count {@link ConfigPropertySchema.enum} values (see + * {@link _createContextTierConfigSchemaProperty}), so the selection arrives in `config[contextTier]` + * as a token count rather than a tier name. This rewrites it to `'default'` / `'long_context'` so + * {@link getCopilotContextTier} and the launcher can consume it unchanged, and so the persisted form + * stays the tier string. * - * The chosen window must be one of the model's {@link IAgentModelInfo.recommendedContextWindows}; - * reaching the largest recommended window selects `long_context`, otherwise the default tier. A - * value the model does not recommend is rejected (no tier is set), so a stale or out-of-range client - * selection cannot silently request an unsupported window. Resolving here — when the selection - * arrives and the model list is freshly loaded — is more reliable than at launch/resume time. - * - * The branded {@link ResolvedModelSelection} return type makes this the only producer of a storable - * model, so the compiler rejects persisting or launching a raw client selection that skipped it. + * The long-context threshold is read from the model's own config-schema enum — the same numbers the + * client picked from — so no model billing needs to be retained. A value the model does not offer (or + * a model without a context-size picker) leaves `config` untouched; an already-resolved tier string + * (e.g. a persisted older selection) is passed through. */ - private _resolveModelSelection(model: ModelSelection): ResolvedModelSelection; - private _resolveModelSelection(model: ModelSelection | undefined): ResolvedModelSelection | undefined; - private _resolveModelSelection(model: ModelSelection | undefined): ResolvedModelSelection | undefined { - if (!model) { - return undefined; - } - const { maxContextWindow, ...rest } = model; - if (typeof maxContextWindow !== 'number') { - return rest as ResolvedModelSelection; - } - const windows = this._models.get().find(m => m.id === model.id)?.recommendedContextWindows; - if (!windows || windows.length === 0 || !windows.includes(maxContextWindow)) { - if (windows && windows.length > 0) { - this._logService.warn(`[Copilot] Ignoring unsupported context window ${maxContextWindow} for model '${model.id}'; expected one of [${windows.join(', ')}]`); - } - return rest as ResolvedModelSelection; - } - const contextTier = mapContextSizeToContextTier(maxContextWindow, Math.max(...windows)); - if (!contextTier) { - return rest as ResolvedModelSelection; - } - const resolved: ModelSelection = { ...rest, config: { ...rest.config, [ContextTierConfigKey]: contextTier } }; - return resolved as ResolvedModelSelection; + private _resolveContextTier(model: ModelSelection): ModelSelection; + private _resolveContextTier(model: ModelSelection | undefined): ModelSelection | undefined; + private _resolveContextTier(model: ModelSelection | undefined): ModelSelection | undefined { + const raw = model?.config?.[ContextTierConfigKey]; + if (raw === undefined || isContextTier(raw)) { + return model; + } + const selectedWindow = Number(raw); + const windows = this._models.get().find(m => m.id === model!.id)?.configSchema?.properties?.[ContextTierConfigKey]?.enum; + const numericWindows = windows?.filter((w): w is number => typeof w === 'number'); + if (!Number.isFinite(selectedWindow) || !numericWindows || numericWindows.length === 0) { + // Unknown / out-of-range size (e.g. a stale client selection): drop it so the SDK keeps its default tier. + this._logService.warn(`[Copilot] Ignoring unresolvable context size '${raw}' for model '${model!.id}'`); + const config = { ...model!.config }; + delete config[ContextTierConfigKey]; + return { ...model!, config: Object.keys(config).length > 0 ? config : undefined }; + } + const contextTier = selectedWindow >= Math.max(...numericWindows) ? 'long_context' : 'default'; + return { ...model!, config: { ...model!.config, [ContextTierConfigKey]: contextTier } }; } /** @@ -826,6 +808,10 @@ export class CopilotAgent extends Disposable implements IAgent { if (thinkingLevel) { properties[ThinkingLevelConfigKey] = thinkingLevel; } + const contextTier = this._createContextTierConfigSchemaProperty(m.billing); + if (contextTier) { + properties[ContextTierConfigKey] = contextTier; + } if (Object.keys(properties).length === 0) { return undefined; } @@ -836,7 +822,7 @@ export class CopilotAgent extends Disposable implements IAgent { return JSON.stringify(model); } - private _parseModelSelection(raw: string | undefined): ResolvedModelSelection | undefined { + private _parseModelSelection(raw: string | undefined): ModelSelection | undefined { if (!raw) { return undefined; } @@ -856,16 +842,13 @@ export class CopilotAgent extends Disposable implements IAgent { modelSelection.config = config; } } - // Persisted selections were already resolved (and stripped of `maxContextWindow`) before - // they were written, so the parsed result is a resolved model. - return modelSelection as ResolvedModelSelection; + return modelSelection; } } catch { // Older session metadata stored the raw model id as a plain string. } - const fallback: ModelSelection = { id: raw }; - return fallback as ResolvedModelSelection; + return { id: raw }; } private _serializeAgentSelection(agent: AgentSelection): string { @@ -989,7 +972,6 @@ export class CopilotAgent extends Disposable implements IAgent { // Synthetic SDK entries like `auto` ship with `capabilities: {}` and // no fixed context window — surface them with maxContextWindow undefined. maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, - recommendedContextWindows: this._getRecommendedContextWindows(m.billing), supportsVision: !!m.capabilities?.supports?.vision, configSchema: this._createModelConfigSchema(m), policyState: m.policy?.state as PolicyState | undefined, @@ -1016,7 +998,7 @@ export class CopilotAgent extends Disposable implements IAgent { } async createSession(config?: IAgentCreateSessionConfig): Promise { - const sessionConfig = { ...(config ?? {}), model: this._resolveModelSelection(config?.model) }; + const sessionConfig = { ...(config ?? {}), model: this._resolveContextTier(config?.model) }; this._logService.info(`[Copilot] Creating session... ${sessionConfig.model ? `model=${sessionConfig.model.id}` : ''}`); const sessionId = sessionConfig.session ? AgentSession.id(sessionConfig.session) : generateUuid(); @@ -1671,7 +1653,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (this._chatSessions.has(chatKey)) { return; } - const model = this._resolveModelSelection(options?.model); + const model = this._resolveContextTier(options?.model); // Resolve the owning session so the new chat inherits its working // directory scope. The parent may be provisional (no SDK session // yet); in that case use its provisional working directory. @@ -1865,7 +1847,7 @@ export class CopilotAgent extends Disposable implements IAgent { } async changeModel(session: URI, model: ModelSelection, chat?: URI): Promise { - const resolved = this._resolveModelSelection(model); + const resolved = this._resolveContextTier(model); // Additional (non-default) chats are backed by their own SDK // conversation tracked in `_chatSessions`; apply the change there and // skip the session-level metadata store (peer chats are not persisted @@ -2249,7 +2231,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (typeof sdkSessionId !== 'string' || !sdkSessionId) { continue; } - result.set(chatId, { sdkSessionId, ...(model ? { model: model as ResolvedModelSelection } : {}) }); + result.set(chatId, { sdkSessionId, ...(model ? { model: model as ModelSelection } : {}) }); } return result; } catch (err) { @@ -2317,7 +2299,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _storeSessionMetadata(session: URI, model: ResolvedModelSelection | undefined, workingDirectory: URI | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): Promise { + private async _storeSessionMetadata(session: URI, model: ModelSelection | undefined, workingDirectory: URI | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): Promise { const dbRef = this._sessionDataService.openDatabase(session); const db = dbRef.object; try { @@ -2344,7 +2326,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _readSessionMetadata(session: URI): Promise<{ model?: ResolvedModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI }> { + private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI }> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return {}; @@ -2367,7 +2349,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _readStoredSessionMetadata(session: URI): Promise<{ model?: ResolvedModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean } | undefined> { + private async _readStoredSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean } | undefined> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return undefined; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 97470ca6b711d..86b056565c4a0 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -124,7 +124,7 @@ function isReasoningEffort(value: string | undefined): value is ReasoningEffort return ReasoningEfforts.some(reasoningEffort => reasoningEffort === value); } -function isContextTier(value: string | undefined): value is ContextTier { +export function isContextTier(value: string | undefined): value is ContextTier { return ContextTiers.some(contextTier => contextTier === value); } diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 7b33e16e935a6..01a10fbcedb89 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -718,7 +718,6 @@ suite('AgentSideEffects', () => { provider: 'mock', name: 'mock Model', maxContextWindow: 128000, - recommendedContextWindows: undefined, supportsVision: false, policyState: undefined, configSchema: undefined, diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 9af3f066047e0..3802b886c551d 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -33,7 +33,7 @@ import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPlu import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentActionSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { buildSubagentSessionUri, buildChatUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, customizationId, type ClientPluginCustomization, type MarkdownResponsePart, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; -import { CustomizationType, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { CustomizationType, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction, type IDeltaAction, type SessionAction } from '../../common/state/sessionActions.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -42,11 +42,11 @@ import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { AgentHostCompletions, IAgentHostCompletions } from '../../node/agentHostCompletions.js'; -import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, getCopilotWorktreeName, getCopilotWorktreesRoot, mapContextSizeToContextTier } from '../../node/copilot/copilotAgent.js'; +import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, getCopilotWorktreeName, getCopilotWorktreesRoot } from '../../node/copilot/copilotAgent.js'; import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; import { CopilotBranchNameGenerator, ICopilotBranchNameGenerator, getCopilotBranchNameHintFromMessage, normalizeCopilotBranchName } from '../../node/copilot/copilotBranchNameGenerator.js'; -import { type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from '../../node/copilot/copilotSessionLauncher.js'; +import type { CopilotSessionLaunchPlan, IActiveClientSnapshot } from '../../node/copilot/copilotSessionLauncher.js'; import { ShellManager } from '../../node/copilot/copilotShellTools.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; @@ -846,7 +846,6 @@ suite('CopilotAgent', () => { id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128000, - recommendedContextWindows: undefined, supportsVision: true, configSchema: undefined, policyState: undefined, @@ -912,18 +911,18 @@ suite('CopilotAgent', () => { const schema = models[0].configSchema; assert.deepStrictEqual(schema?.properties.thinkingLevel?.enum, ['low', 'medium', 'high']); assert.strictEqual(schema?.properties.thinkingLevel?.default, 'medium'); - assert.strictEqual(schema?.properties.contextSize, undefined); + assert.strictEqual(schema?.properties.contextTier, undefined); } finally { await disposeAgent(agent); } }); - test('models advertise recommendedContextWindows when long_context tier exceeds default', async () => { + test('configSchema emits a numeric contextTier property when long_context tier exceeds default', async () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [{ id: 'claude-sonnet', name: 'Claude Sonnet', - capabilities: { limits: { max_context_window_tokens: 1_000_000 } }, + capabilities: { limits: { max_context_window_tokens: 200_000 } }, billing: { multiplier: 1, tokenPrices: { @@ -937,17 +936,17 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const models = await waitForState(agent.models, models => models.length > 0); - // The selectable windows ride on the models list, not a numeric config property; the true - // (long) maximum stays on `maxContextWindow`. - assert.deepStrictEqual(models[0].recommendedContextWindows, [200_000, 1_000_000]); - assert.strictEqual(models[0].maxContextWindow, 1_000_000); - assert.strictEqual(models[0].configSchema?.properties.contextSize, undefined); + const contextTier = models[0].configSchema?.properties.contextTier; + assert.strictEqual(contextTier?.type, 'number'); + assert.deepStrictEqual(contextTier?.enum, [200_000, 1_000_000]); + assert.strictEqual(contextTier?.default, 200_000); + assert.deepStrictEqual(contextTier?.enumLabels, ['200K', '1M']); } finally { await disposeAgent(agent); } }); - test('models omit recommendedContextWindows when long_context tier is missing or not larger', async () => { + test('configSchema omits contextTier when long_context tier is missing or not larger', async () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [ { @@ -969,37 +968,77 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const models = await waitForState(agent.models, models => models.length > 0); - assert.strictEqual(models[0].recommendedContextWindows, undefined); - assert.strictEqual(models[1].recommendedContextWindows, undefined); + assert.strictEqual(models[0].configSchema, undefined); + assert.strictEqual(models[1].configSchema, undefined); } finally { await disposeAgent(agent); } }); - test('mapContextSizeToContextTier only selects long context when the long window is reached', () => { - const defaultWindow = 200_000; - const longWindow = 1_000_000; - assert.deepStrictEqual( - { - unset: mapContextSizeToContextTier(undefined, longWindow), - noWindow: mapContextSizeToContextTier(longWindow, undefined), - exactDefault: mapContextSizeToContextTier(defaultWindow, longWindow), - // A value nudged just above the default must NOT round up into long context. - roundedUpDefault: mapContextSizeToContextTier(defaultWindow + 1, longWindow), - justBelowLong: mapContextSizeToContextTier(longWindow - 1, longWindow), - exactLong: mapContextSizeToContextTier(longWindow, longWindow), - aboveLong: mapContextSizeToContextTier(longWindow + 1, longWindow), - }, - { - unset: undefined, - noWindow: undefined, - exactDefault: 'default', - roundedUpDefault: 'default', - justBelowLong: 'default', - exactLong: 'long_context', - aboveLong: 'long_context', + suite('contextTier resolution', () => { + const longContextModel: ITestCopilotModelInfo = { + id: 'claude-sonnet', + name: 'Claude Sonnet', + capabilities: { limits: { max_context_window_tokens: 200_000 } }, + billing: { + multiplier: 1, + tokenPrices: { + contextMax: 200_000, + longContext: { contextMax: 1_000_000, inputPrice: 2 }, + }, }, - ); + }; + + async function captureSessionConfig(model: ModelSelection | undefined, models: readonly ITestCopilotModelInfo[]): Promise { + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([], models); + let capturedConfig: CopilotCreateSessionOptions | undefined; + client.createSession = async config => { + capturedConfig = config; + return new MockCopilotSession() as unknown as CopilotSession; + }; + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await waitForState(agent.models, m => m.length > 0); + const result = await agent.createSession({ + session: AgentSession.uri('copilotcli', 'ctx-session'), + workingDirectory: URI.file('/workspace'), + ...(model ? { model } : {}), + }); + await agent.sendMessage(result.session, 'hello'); + return capturedConfig; + } finally { + await disposeAgent(agent); + } + } + + test('maps the largest numeric context window to long_context', async () => { + const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextTier: '1000000' } }, [longContextModel]); + assert.ok(config, 'SDK createSession should be called during materialization'); + assert.strictEqual(config.contextTier, 'long_context'); + }); + + test('maps the default numeric context window to default', async () => { + const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextTier: '200000' } }, [longContextModel]); + assert.ok(config); + assert.strictEqual(config.contextTier, 'default'); + }); + + test('drops a numeric context window the model does not offer', async () => { + const config = await captureSessionConfig( + { id: 'no-context-picker', config: { contextTier: '1000000' } }, + [{ id: 'no-context-picker', name: 'No Picker' }], + ); + assert.ok(config); + assert.strictEqual(config.contextTier, undefined); + }); + + test('passes through an already-resolved tier string', async () => { + const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextTier: 'long_context' } }, [longContextModel]); + assert.ok(config); + assert.strictEqual(config.contextTier, 'long_context'); + }); }); test('agent-created sessions can resolve session-state paths via INativeEnvironmentService', async () => { @@ -1674,56 +1713,6 @@ suite('CopilotAgent', () => { } }); - test('materialization maps the selected context window to the SDK context tier', async () => { - const longContextModel: ITestCopilotModelInfo = { - id: 'claude-sonnet', - name: 'Claude Sonnet', - capabilities: { limits: { max_context_window_tokens: 1_000_000 } }, - billing: { - multiplier: 1, - tokenPrices: { - contextMax: 200_000, - longContext: { contextMax: 1_000_000, inputPrice: 2 }, - }, - }, - }; - - const materializeWithSelectedWindow = async (selectedWindow: number | undefined): Promise => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([], [longContextModel]); - let capturedConfig: Parameters[0] | undefined; - client.createSession = async config => { - capturedConfig = config; - return new MockCopilotSession() as unknown as CopilotSession; - }; - - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); - try { - await agent.authenticate('https://api.github.com', 'token'); - await waitForState(agent.models, models => models.length > 0); - - const result = await agent.createSession({ - session: AgentSession.uri('copilotcli', `ctx-${selectedWindow ?? 'none'}`), - workingDirectory: URI.file('/workspace'), - model: { id: 'claude-sonnet', ...(selectedWindow !== undefined ? { maxContextWindow: selectedWindow } : {}) }, - }); - await agent.sendMessage(result.session, 'hello'); - return capturedConfig?.contextTier ?? undefined; - } finally { - await disposeAgent(agent); - } - }; - - assert.deepStrictEqual( - { - long: await materializeWithSelectedWindow(1_000_000), - default: await materializeWithSelectedWindow(200_000), - unset: await materializeWithSelectedWindow(undefined), - }, - { long: 'long_context', default: 'default', unset: undefined }, - ); - }); - test('materialization forwards the GitHub token to the SDK at the session level (#318693)', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const client = new TestCopilotClient([]); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts index bd9d0073ae692..ed80227052433 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts @@ -161,7 +161,7 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { if (!schema || !this._isWellKnownSchema(schema)) { return undefined; } - const enumValues = schema.enum ?? []; + const enumValues = (schema.enum ?? []).map(value => String(value)); const enumLabels = schema.enumLabels ?? []; const enumDescriptions = schema.enumDescriptions ?? []; const items: IAgentHostSessionEnumPickerItem[] = enumValues.map((value, index) => ({ diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts index 9d20acfddb19d..ae1af45ac56c9 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts @@ -37,7 +37,7 @@ export function isWellKnownAutoApproveSchema(schema: SessionConfigPropertySchema if (!schema.enum.includes(REQUIRED_AUTO_APPROVE_VALUE)) { return false; } - return schema.enum.every(value => KNOWN_AUTO_APPROVE_VALUES.has(value)); + return schema.enum.every(value => typeof value === 'string' && KNOWN_AUTO_APPROVE_VALUES.has(value)); } /** diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index 16f015e1264a0..1d3f5c71e3a3c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -477,8 +477,8 @@ export class AgentHostSessionConfigPicker extends Disposable { } return (schema.enum ?? []).map((value, index) => ({ - value, - label: schema.enumLabels?.[index] ?? value, + value: String(value), + label: schema.enumLabels?.[index] ?? String(value), description: schema.enumDescriptions?.[index], })); } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts index 6ef444c12cd31..72ff83895c6c9 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts @@ -202,8 +202,8 @@ class MobileChatInputConfigPicker extends Disposable { const modeSchema = config?.schema.properties[SessionConfigKey.Mode]; const modeItems = (modeSchema && isWellKnownModeSchema(modeSchema)) ? (modeSchema.enum ?? []).map((value, index) => ({ - value, - label: modeSchema.enumLabels?.[index] ?? value, + value: String(value), + label: modeSchema.enumLabels?.[index] ?? String(value), description: modeSchema.enumDescriptions?.[index], })) : []; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts index d1f54a7d65bab..463431b6f4384 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts @@ -124,8 +124,8 @@ class MobileChatPhoneInputPresenter extends Disposable implements IChatPhonePres const modeSchema = config?.schema.properties[SessionConfigKey.Mode]; const modeItems = (modeSchema && isWellKnownModeSchema(modeSchema)) ? (modeSchema.enum ?? []).map((value, index) => ({ - value, - label: modeSchema.enumLabels?.[index] ?? value, + value: String(value), + label: modeSchema.enumLabels?.[index] ?? String(value), description: modeSchema.enumDescriptions?.[index], })) : []; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index 2eeb08726c475..0b7eec3fa94b9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -154,7 +154,7 @@ export function isWellKnownAutoApproveSchema(schema: SessionConfigPropertySchema if (!schema.enum.includes('default')) { return false; } - return schema.enum.every(value => KNOWN_AUTO_APPROVE_VALUES.has(value)); + return schema.enum.every(value => typeof value === 'string' && KNOWN_AUTO_APPROVE_VALUES.has(value)); } /** @@ -570,8 +570,8 @@ export class AgentHostChatInputPicker extends Disposable { } } return (schema.enum ?? []).map((value, index) => ({ - value, - label: schema.enumLabels?.[index] ?? value, + value: String(value), + label: schema.enumLabels?.[index] ?? String(value), description: schema.enumDescriptions?.[index], })); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts index ff54c4658676d..76739a8cdd925 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -6,23 +6,11 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { formatTokenCount } from '../../../../../../base/common/numbers.js'; -import { localize } from '../../../../../../nls.js'; -import { SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ConfigSchema, SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { readAgentModelPricingMeta } from '../../../../../../platform/agentHost/common/agentModelPricing.js'; import { nullExtensionDescription } from '../../../../../services/extensions/common/extensions.js'; import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelConfigurationSchema } from '../../../common/languageModels.js'; -/** - * Config key under which the synthesized context-size picker is exposed. The chosen value is a token - * count carried back via `ModelSelection.maxContextWindow`; the `tokens` group marks it so the chat - * model picker renders it as the "Context Size" control. - */ -const ContextSizeConfigKey = 'contextSize'; - -type ConfigurationSchemaProperty = NonNullable[string]; - - /** * Returns whether an agent host provider exposes a synthetic "Auto" model to * fall back to. @@ -103,75 +91,38 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu toolCalling: true, agentMode: true, }, - configurationSchema: this._toLanguageModelConfigurationSchema(m), + configurationSchema: this._toLanguageModelConfigurationSchema(m.configSchema), }, }; }); } - /** - * Builds the language-model configuration schema for a model: the agent-provided properties (e.g. - * `thinkingLevel`) plus a synthesized numeric context-size picker derived from the model's - * {@link SessionModelInfo.recommendedContextWindows}. Keeping the context-size picker out of the - * agent-host protocol config (it lives on the models list instead) means the chosen value rides on - * the typed `ModelSelection.maxContextWindow` rather than the generic config bag. - */ - private _toLanguageModelConfigurationSchema(m: SessionModelInfo): ILanguageModelConfigurationSchema | undefined { - const properties: Record = {}; - - if (m.configSchema) { - for (const [key, property] of Object.entries(m.configSchema.properties)) { - properties[key] = { - type: property.type, - title: property.title, - description: property.description, - default: property.default, - enum: property.enum, - enumItemLabels: property.enumLabels, - enumDescriptions: property.enumDescriptions, - group: AgentHostLanguageModelProvider._groupForConfigKey(key), - }; - } - } - - const contextSize = AgentHostLanguageModelProvider._createContextSizeSchemaProperty(m.recommendedContextWindows); - if (contextSize) { - properties[ContextSizeConfigKey] = contextSize; - } - - if (Object.keys(properties).length === 0) { + private _toLanguageModelConfigurationSchema(schema: ConfigSchema | undefined): ILanguageModelConfigurationSchema | undefined { + if (!schema) { return undefined; } - return { type: 'object', required: m.configSchema?.required, properties }; - } - - /** - * Synthesizes the numeric "Context Size" picker property from a model's recommended context - * windows (smallest first; the first is the default). Returns `undefined` when the model offers - * fewer than two windows, so no picker is shown. - */ - private static _createContextSizeSchemaProperty(windows: readonly number[] | undefined): ConfigurationSchemaProperty | undefined { - if (!windows || windows.length < 2) { - return undefined; - } return { - type: 'number', - title: localize('agentHost.contextSize.title', "Context Size"), - description: localize('agentHost.contextSize.description', "Selects the context window size for this model."), - default: windows[0], - enum: [...windows], - enumItemLabels: windows.map(window => formatTokenCount(window)), - enumDescriptions: windows.map((_, index) => index === 0 - ? localize('agentHost.contextSize.default', "Default") - : localize('agentHost.contextSize.longer', "Longer sessions")), - group: 'tokens', + type: schema.type, + required: schema.required, + properties: Object.fromEntries(Object.entries(schema.properties).map(([key, property]) => [key, { + type: property.type, + title: property.title, + description: property.description, + default: property.default, + enum: property.enum, + enumItemLabels: property.enumLabels, + enumDescriptions: property.enumDescriptions, + readOnly: property.readOnly, + group: AgentHostLanguageModelProvider._groupForConfigKey(key), + }])), }; } private static _groupForConfigKey(key: string): string | undefined { switch (key) { case 'thinkingLevel': return 'navigation'; + case 'contextTier': return 'tokens'; default: return undefined; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index aa92cd4a71fc1..ee9e6e47bbdea 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -2960,40 +2960,18 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return undefined; } - // The model's configuration schema marks its context-window property with the `tokens` - // group. That selection is a real token count, so it is carried as the typed - // `maxContextWindow` field rather than stuffed into the string `config` bag; the rest of the - // (string) configuration flows through `config` as before. - const schemaProperties = languageModelIdentifier - ? this._languageModelsService.lookupLanguageModel(languageModelIdentifier)?.configurationSchema?.properties - : undefined; - const config: Record = {}; - let maxContextWindow: number | undefined; for (const [key, value] of Object.entries(modelConfiguration ?? {})) { - if (typeof value === 'number') { - if (schemaProperties?.[key]?.group === 'tokens') { - maxContextWindow = value; - } - continue; - } if (typeof value === 'string') { config[key] = value; } } - const selection: ModelSelection = { id: rawModelId }; - if (Object.keys(config).length > 0) { - selection.config = config; - } - if (maxContextWindow !== undefined) { - selection.maxContextWindow = maxContextWindow; - } - return selection; + return Object.keys(config).length > 0 ? { id: rawModelId, config } : { id: rawModelId }; } private _modelSelectionsEqual(a: ModelSelection | undefined, b: ModelSelection | undefined): boolean { - if (a?.id !== b?.id || a?.maxContextWindow !== b?.maxContextWindow) { + if (a?.id !== b?.id) { return false; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index b7f2724c69cf9..375b2b370cedd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -1889,35 +1889,6 @@ suite('AgentHostChatContribution', () => { assert.deepStrictEqual(agentHostService.createSessionCalls[0].model, { id: 'claude-sonnet-4-20250514', config: { thinkingLevel: 'high' } }); })); - test('lifts the selected context window into maxContextWindow instead of the config bag', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const languageModels = new Map([ - ['agent-host-copilot:claude-sonnet-4-20250514', upcastPartial({ - configurationSchema: { - properties: { - thinkingLevel: { type: 'string', group: 'navigation', enum: ['low', 'high'] }, - contextSize: { type: 'number', group: 'tokens', enum: [200_000, 1_000_000] }, - }, - }, - })], - ]); - const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables, { languageModels }); - - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { - message: 'Hi', - userSelectedModelId: 'agent-host-copilot:claude-sonnet-4-20250514', - modelConfiguration: { thinkingLevel: 'high', contextSize: 1_000_000 }, - }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); - await turnPromise; - - assert.strictEqual(agentHostService.createSessionCalls.length, 1); - assert.deepStrictEqual(agentHostService.createSessionCalls[0].model, { - id: 'claude-sonnet-4-20250514', - config: { thinkingLevel: 'high' }, - maxContextWindow: 1_000_000, - }); - })); - test('passes model id as-is when no vendor prefix', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); @@ -3794,48 +3765,11 @@ suite('AgentHostChatContribution', () => { enum: ['low', 'medium', 'high'], enumItemLabels: ['Low', 'Medium', 'High'], enumDescriptions: undefined, + readOnly: undefined, group: 'navigation', }); }); - test('synthesizes a tokens-group context size picker from recommendedContextWindows', async () => { - const provider = disposables.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot')); - provider.updateModels([ - { - provider: 'copilot', - id: 'claude-sonnet', - name: 'Claude Sonnet', - maxContextWindow: 1_000_000, - recommendedContextWindows: [200_000, 1_000_000], - supportsVision: false, - }, - ]); - - const models = await provider.provideLanguageModelChatInfo({}, CancellationToken.None); - - assert.deepStrictEqual(models[0].metadata.configurationSchema?.properties?.contextSize, { - type: 'number', - title: 'Context Size', - description: 'Selects the context window size for this model.', - default: 200_000, - enum: [200_000, 1_000_000], - enumItemLabels: ['200K', '1M'], - enumDescriptions: ['Default', 'Longer sessions'], - group: 'tokens', - }); - }); - - test('omits the context size picker when fewer than two recommended windows', async () => { - const provider = disposables.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot')); - provider.updateModels([ - { provider: 'copilot', id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128_000, supportsVision: false }, - ]); - - const models = await provider.provideLanguageModelChatInfo({}, CancellationToken.None); - - assert.strictEqual(models[0].metadata.configurationSchema, undefined); - }); - test('returns empty when no models set', async () => { const provider = disposables.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot')); From 99ee48185e41a3bcf4e8b1779381ff20197c9fb4 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 23 Jun 2026 13:48:01 -0700 Subject: [PATCH 07/18] wip --- .../agentHost/node/copilot/copilotAgent.ts | 66 ++++++++----------- .../node/copilot/copilotSessionLauncher.ts | 29 +++++++- 2 files changed, 52 insertions(+), 43 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index cbcdefb4b04ba..d26c10168e8e7 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -52,7 +52,7 @@ import { ICopilotBranchNameGenerator } from './copilotBranchNameGenerator.js'; import { CopilotAgentSession, type CopilotSdkMode } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; -import { CopilotSessionLauncher, ContextTierConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, isContextTier, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +import { CopilotSessionLauncher, ContextTierConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { ShellManager } from './copilotShellTools.js'; import { isRestrictedTelemetryEnabled } from './copilotTokenFields.js'; import { CopilotSlashCommandCompletionProvider } from './copilotSlashCommandCompletionProvider.js'; @@ -703,8 +703,9 @@ export class CopilotAgent extends Disposable implements IAgent { * `extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts`. * * The `enum` values are the two context-window sizes (in tokens), smallest first, so the numeric token counts - * flow to the client; the chosen value is mapped back to the SDK's two-valued `contextTier` by - * {@link _resolveContextTier} when the selection returns. + * flow to the client. The chosen value comes back in the model's `config` bag and is mapped to the SDK's + * two-valued `contextTier` at the SDK boundary by {@link getCopilotContextTier}, using the model's long-context + * window from {@link _longContextWindowFor}. * * `billing.tokenPrices` is present on the runtime CAPI `/models` payload but not yet declared on the published SDK * `ModelBilling` type — narrow through {@link ICopilotModelBilling} until the SDK catches up. @@ -737,37 +738,19 @@ export class CopilotAgent extends Disposable implements IAgent { } /** - * Maps a model selection received from a client back onto the SDK's two-valued `contextTier`. The - * "Context Size" picker exposes numeric token-count {@link ConfigPropertySchema.enum} values (see - * {@link _createContextTierConfigSchemaProperty}), so the selection arrives in `config[contextTier]` - * as a token count rather than a tier name. This rewrites it to `'default'` / `'long_context'` so - * {@link getCopilotContextTier} and the launcher can consume it unchanged, and so the persisted form - * stays the tier string. - * - * The long-context threshold is read from the model's own config-schema enum — the same numbers the - * client picked from — so no model billing needs to be retained. A value the model does not offer (or - * a model without a context-size picker) leaves `config` untouched; an already-resolved tier string - * (e.g. a persisted older selection) is passed through. + * The model's long-context window (in tokens): the largest size offered by its "Context Size" picker + * (the max numeric value in the synthesized `contextTier` {@link ConfigPropertySchema.enum}). Used by + * {@link getCopilotContextTier} to decide whether a numeric selection opts into `long_context`. + * Returns `undefined` when the model exposes no such picker (or the model list isn't loaded yet), + * leaving the SDK on its default tier. */ - private _resolveContextTier(model: ModelSelection): ModelSelection; - private _resolveContextTier(model: ModelSelection | undefined): ModelSelection | undefined; - private _resolveContextTier(model: ModelSelection | undefined): ModelSelection | undefined { - const raw = model?.config?.[ContextTierConfigKey]; - if (raw === undefined || isContextTier(raw)) { - return model; - } - const selectedWindow = Number(raw); - const windows = this._models.get().find(m => m.id === model!.id)?.configSchema?.properties?.[ContextTierConfigKey]?.enum; - const numericWindows = windows?.filter((w): w is number => typeof w === 'number'); - if (!Number.isFinite(selectedWindow) || !numericWindows || numericWindows.length === 0) { - // Unknown / out-of-range size (e.g. a stale client selection): drop it so the SDK keeps its default tier. - this._logService.warn(`[Copilot] Ignoring unresolvable context size '${raw}' for model '${model!.id}'`); - const config = { ...model!.config }; - delete config[ContextTierConfigKey]; - return { ...model!, config: Object.keys(config).length > 0 ? config : undefined }; + private _longContextWindowFor(modelId: string | undefined): number | undefined { + if (!modelId) { + return undefined; } - const contextTier = selectedWindow >= Math.max(...numericWindows) ? 'long_context' : 'default'; - return { ...model!, config: { ...model!.config, [ContextTierConfigKey]: contextTier } }; + const windows = this._models.get().find(m => m.id === modelId)?.configSchema?.properties?.[ContextTierConfigKey]?.enum; + const numericWindows = windows?.filter((w): w is number => typeof w === 'number'); + return numericWindows && numericWindows.length > 0 ? Math.max(...numericWindows) : undefined; } /** @@ -998,7 +981,7 @@ export class CopilotAgent extends Disposable implements IAgent { } async createSession(config?: IAgentCreateSessionConfig): Promise { - const sessionConfig = { ...(config ?? {}), model: this._resolveContextTier(config?.model) }; + const sessionConfig = config ?? {}; this._logService.info(`[Copilot] Creating session... ${sessionConfig.model ? `model=${sessionConfig.model.id}` : ''}`); const sessionId = sessionConfig.session ? AgentSession.id(sessionConfig.session) : generateUuid(); @@ -1192,6 +1175,7 @@ export class CopilotAgent extends Disposable implements IAgent { shellManager, githubToken: this._githubToken, model: provisional.model, + longContextWindow: this._longContextWindowFor(provisional.model?.id), }; agentSession = this._createAgentSession(launchPlan, customizationDirectory, activeClient); await agentSession.initializeSession(); @@ -1653,7 +1637,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (this._chatSessions.has(chatKey)) { return; } - const model = this._resolveContextTier(options?.model); + const model = options?.model; // Resolve the owning session so the new chat inherits its working // directory scope. The parent may be provisional (no SDK session // yet); in that case use its provisional working directory. @@ -1683,6 +1667,7 @@ export class CopilotAgent extends Disposable implements IAgent { shellManager, githubToken: this._githubToken, model, + longContextWindow: this._longContextWindowFor(model?.id), }; let agentSession: CopilotAgentSession | undefined; try { @@ -1796,7 +1781,7 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientState: activeClient.state, shellManager, githubToken: this._githubToken, - fallback: { model: info.model }, + fallback: { model: info.model, longContextWindow: this._longContextWindowFor(info.model?.id) }, }; let agentSession: CopilotAgentSession | undefined; try { @@ -1847,26 +1832,26 @@ export class CopilotAgent extends Disposable implements IAgent { } async changeModel(session: URI, model: ModelSelection, chat?: URI): Promise { - const resolved = this._resolveContextTier(model); + const longContextWindow = this._longContextWindowFor(model.id); // Additional (non-default) chats are backed by their own SDK // conversation tracked in `_chatSessions`; apply the change there and // skip the session-level metadata store (peer chats are not persisted // per-chat). if (chat && !isDefaultChatUri(chat)) { - await this._chatSessions.get(chat.toString())?.setModel(resolved.id, getCopilotReasoningEffort(resolved), getCopilotContextTier(resolved)); + await this._chatSessions.get(chat.toString())?.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model, longContextWindow)); return; } const sessionId = AgentSession.id(session); const provisional = this._provisionalSessions.get(sessionId); if (provisional) { - provisional.model = resolved; + provisional.model = model; return; } const entry = this._sessions.get(sessionId); if (entry) { - await entry.setModel(resolved.id, getCopilotReasoningEffort(resolved), getCopilotContextTier(resolved)); + await entry.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model, longContextWindow)); } - await this._storeSessionMetadata(session, resolved, undefined, undefined, undefined); + await this._storeSessionMetadata(session, model, undefined, undefined, undefined); } async changeAgent(session: URI, agent: AgentSelection | undefined, chat?: URI): Promise { @@ -2112,6 +2097,7 @@ export class CopilotAgent extends Disposable implements IAgent { githubToken: this._githubToken, fallback: { model: storedMetadata.model, + longContextWindow: this._longContextWindowFor(storedMetadata.model?.id), }, }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 86b056565c4a0..2eadec9f78be5 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -108,6 +108,11 @@ interface ICopilotSessionLaunchBase { export interface ICopilotCreateSessionLaunchPlan extends ICopilotSessionLaunchBase { readonly kind: 'create'; readonly model: ModelSelection | undefined; + /** + * Long-context window (in tokens) for {@link model}, used by {@link getCopilotContextTier} to map a + * numeric "Context Size" selection onto the SDK tier. Absent when the model exposes no such window. + */ + readonly longContextWindow?: number; } export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBase { @@ -115,6 +120,8 @@ export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBa readonly workingDirectory: URI; readonly fallback: { readonly model: ModelSelection | undefined; + /** Long-context window for {@link fallback.model}; see {@link ICopilotCreateSessionLaunchPlan.longContextWindow}. */ + readonly longContextWindow?: number; }; } @@ -177,9 +184,24 @@ export function getCopilotReasoningEffort(model: ModelSelection | undefined): Se return isReasoningEffort(thinkingLevel) ? thinkingLevel : undefined; } -export function getCopilotContextTier(model: ModelSelection | undefined): SessionConfig['contextTier'] { +export function getCopilotContextTier(model: ModelSelection | undefined, longContextWindow?: number): SessionConfig['contextTier'] { const contextTier = model?.config?.[ContextTierConfigKey]; - return isContextTier(contextTier) ? contextTier : undefined; + if (contextTier === undefined) { + return undefined; + } + // Persisted/older selections already store the resolved tier string. + if (isContextTier(contextTier)) { + return contextTier; + } + // The "Context Size" picker exposes numeric token-count enum values, so a selection arrives here as + // a token count. Map it to the SDK's two-valued tier using the model's long-context window: only a + // selection that reaches that window opts into `long_context`. Without the window (model exposes no + // picker, or the model list isn't loaded) leave the SDK on its default tier. + const selectedWindow = Number(contextTier); + if (!Number.isFinite(selectedWindow) || typeof longContextWindow !== 'number') { + return undefined; + } + return selectedWindow >= longContextWindow ? 'long_context' : 'default'; } export class CopilotSessionLauncher implements ICopilotSessionLauncher { @@ -224,6 +246,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { ...plan, kind: 'create', model: plan.fallback.model, + longContextWindow: plan.fallback.longContextWindow, }, config, sandboxConfig); this._logService.info(`[Copilot:${plan.sessionId}] Fallback createSession succeeded`); return wrapper; @@ -237,7 +260,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { streaming: true, model: plan.model?.id, reasoningEffort: getCopilotReasoningEffort(plan.model), - contextTier: getCopilotContextTier(plan.model), + contextTier: getCopilotContextTier(plan.model, plan.longContextWindow), ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), workingDirectory: plan.workingDirectory?.fsPath, }); From e9a4c5c4629ef3e657e8ab46663d16d46b8155b1 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 23 Jun 2026 14:03:07 -0700 Subject: [PATCH 08/18] clean --- .../common/state/protocol/common/state.ts | 2 +- .../agentHost/node/copilot/copilotAgent.ts | 26 ++++++------- .../node/copilot/copilotSessionLauncher.ts | 39 +++++++++++-------- .../agentHost/test/node/copilotAgent.test.ts | 32 +++++++-------- .../agentHostLanguageModelProvider.ts | 2 +- .../agentHost/agentHostSessionHandler.ts | 12 ++++++ .../agentHostChatContribution.test.ts | 26 +++++++++++++ 7 files changed, 92 insertions(+), 47 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/protocol/common/state.ts b/src/vs/platform/agentHost/common/state/protocol/common/state.ts index 5a2e4939cce83..578c4ccc1948a 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/state.ts @@ -161,7 +161,7 @@ export interface ConfigPropertySchema { description?: string; /** JSON Schema: default value */ default?: unknown; - /** JSON Schema: allowed values. May be primitives of any JSON type (e.g. numeric token counts). */ + /** JSON Schema: allowed values. May be primitives of any JSON type. */ enum?: (string | number | boolean | null)[]; /** Display extension: human-readable label per enum value (parallel array) */ enumLabels?: string[]; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index d26c10168e8e7..50ff2e50d0677 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -52,7 +52,7 @@ import { ICopilotBranchNameGenerator } from './copilotBranchNameGenerator.js'; import { CopilotAgentSession, type CopilotSdkMode } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; -import { CopilotSessionLauncher, ContextTierConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +import { CopilotSessionLauncher, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { ShellManager } from './copilotShellTools.js'; import { isRestrictedTelemetryEnabled } from './copilotTokenFields.js'; import { CopilotSlashCommandCompletionProvider } from './copilotSlashCommandCompletionProvider.js'; @@ -698,7 +698,7 @@ export class CopilotAgent extends Disposable implements IAgent { } /** - * Synthesize a `contextTier` config property when the model exposes a `long_context` pricing tier with a distinct + * Synthesize a `contextSize` config property when the model exposes a `long_context` pricing tier with a distinct * context-max. Picker surfaces this as the "Context Size" button. Mirrors `getContextSizeOptions` in * `extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts`. * @@ -710,7 +710,7 @@ export class CopilotAgent extends Disposable implements IAgent { * `billing.tokenPrices` is present on the runtime CAPI `/models` payload but not yet declared on the published SDK * `ModelBilling` type — narrow through {@link ICopilotModelBilling} until the SDK catches up. */ - private _createContextTierConfigSchemaProperty(billing: ModelInfo['billing'] | undefined): ConfigPropertySchema | undefined { + private _createContextSizeConfigSchemaProperty(billing: ModelInfo['billing'] | undefined): ConfigPropertySchema | undefined { const tokenPrices = billing?.tokenPrices; const defaultMax = tokenPrices?.contextMax; const longContextMax = tokenPrices?.longContext?.contextMax; @@ -723,23 +723,23 @@ export class CopilotAgent extends Disposable implements IAgent { return { type: 'number', - title: localize('copilot.modelContextTier.title', "Context Size"), - description: localize('copilot.modelContextTier.description', "Selects the context window size for this model."), + title: localize('copilot.modelContextSize.title', "Context Size"), + description: localize('copilot.modelContextSize.description', "Selects the context window size for this model."), default: defaultMax, enum: [defaultMax, longContextMax], enumLabels: [formatTokenCount(defaultMax), formatTokenCount(longContextMax)], enumDescriptions: [ - localize('copilot.modelContextTier.default', "Default"), + localize('copilot.modelContextSize.default', "Default"), hasLongContextSurcharge - ? localize('copilot.modelContextTier.longerSessions', "Longer sessions") - : localize('copilot.modelContextTier.longerSessionsNoCompaction', "Longer sessions without compaction"), + ? localize('copilot.modelContextSize.longerSessions', "Longer sessions") + : localize('copilot.modelContextSize.longerSessionsNoCompaction', "Longer sessions without compaction"), ], }; } /** * The model's long-context window (in tokens): the largest size offered by its "Context Size" picker - * (the max numeric value in the synthesized `contextTier` {@link ConfigPropertySchema.enum}). Used by + * (the max numeric value in the synthesized `contextSize` {@link ConfigPropertySchema.enum}). Used by * {@link getCopilotContextTier} to decide whether a numeric selection opts into `long_context`. * Returns `undefined` when the model exposes no such picker (or the model list isn't loaded yet), * leaving the SDK on its default tier. @@ -748,7 +748,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (!modelId) { return undefined; } - const windows = this._models.get().find(m => m.id === modelId)?.configSchema?.properties?.[ContextTierConfigKey]?.enum; + const windows = this._models.get().find(m => m.id === modelId)?.configSchema?.properties?.[ContextSizeConfigKey]?.enum; const numericWindows = windows?.filter((w): w is number => typeof w === 'number'); return numericWindows && numericWindows.length > 0 ? Math.max(...numericWindows) : undefined; } @@ -791,9 +791,9 @@ export class CopilotAgent extends Disposable implements IAgent { if (thinkingLevel) { properties[ThinkingLevelConfigKey] = thinkingLevel; } - const contextTier = this._createContextTierConfigSchemaProperty(m.billing); - if (contextTier) { - properties[ContextTierConfigKey] = contextTier; + const contextSize = this._createContextSizeConfigSchemaProperty(m.billing); + if (contextSize) { + properties[ContextSizeConfigKey] = contextSize; } if (Object.keys(properties).length === 0) { return undefined; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 2eadec9f78be5..c54d6aa4cd0df 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -26,6 +26,16 @@ import { agentHostPromptRegistry, type IAgentHostPromptContext } from './prompts import './prompts/allPrompts.js'; export const ThinkingLevelConfigKey = 'thinkingLevel'; +/** + * Config key for the numeric "Context Size" selection (a context-window token count). Mapped to the + * SDK's two-valued {@link SessionConfig.contextTier} by {@link getCopilotContextTier}. + */ +export const ContextSizeConfigKey = 'contextSize'; +/** + * @deprecated Legacy config key that stored the resolved tier string (`'default'` / `'long_context'`) + * directly. Replaced by the numeric {@link ContextSizeConfigKey}; still read from persisted sessions + * for backward compatibility. + */ export const ContextTierConfigKey = 'contextTier'; const ReasoningEfforts = ['low', 'medium', 'high', 'xhigh'] as const; @@ -108,10 +118,6 @@ interface ICopilotSessionLaunchBase { export interface ICopilotCreateSessionLaunchPlan extends ICopilotSessionLaunchBase { readonly kind: 'create'; readonly model: ModelSelection | undefined; - /** - * Long-context window (in tokens) for {@link model}, used by {@link getCopilotContextTier} to map a - * numeric "Context Size" selection onto the SDK tier. Absent when the model exposes no such window. - */ readonly longContextWindow?: number; } @@ -120,7 +126,6 @@ export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBa readonly workingDirectory: URI; readonly fallback: { readonly model: ModelSelection | undefined; - /** Long-context window for {@link fallback.model}; see {@link ICopilotCreateSessionLaunchPlan.longContextWindow}. */ readonly longContextWindow?: number; }; } @@ -185,19 +190,21 @@ export function getCopilotReasoningEffort(model: ModelSelection | undefined): Se } export function getCopilotContextTier(model: ModelSelection | undefined, longContextWindow?: number): SessionConfig['contextTier'] { - const contextTier = model?.config?.[ContextTierConfigKey]; - if (contextTier === undefined) { - return undefined; + // Legacy persisted selections stored the resolved tier string directly under the deprecated key. + const legacyTier = model?.config?.[ContextTierConfigKey]; + if (isContextTier(legacyTier)) { + return legacyTier; } - // Persisted/older selections already store the resolved tier string. - if (isContextTier(contextTier)) { - return contextTier; + // The "Context Size" picker exposes numeric token-count enum values, so a current selection arrives + // under `contextSize` as a token count. Map it to the SDK's two-valued tier using the model's + // long-context window: only a selection that reaches that window opts into `long_context`. Without + // the window (model exposes no picker, or the model list isn't loaded) leave the SDK on its default + // tier. + const contextSize = model?.config?.[ContextSizeConfigKey]; + if (contextSize === undefined) { + return undefined; } - // The "Context Size" picker exposes numeric token-count enum values, so a selection arrives here as - // a token count. Map it to the SDK's two-valued tier using the model's long-context window: only a - // selection that reaches that window opts into `long_context`. Without the window (model exposes no - // picker, or the model list isn't loaded) leave the SDK on its default tier. - const selectedWindow = Number(contextTier); + const selectedWindow = Number(contextSize); if (!Number.isFinite(selectedWindow) || typeof longContextWindow !== 'number') { return undefined; } diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 3802b886c551d..5a0705a555ed5 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -911,13 +911,13 @@ suite('CopilotAgent', () => { const schema = models[0].configSchema; assert.deepStrictEqual(schema?.properties.thinkingLevel?.enum, ['low', 'medium', 'high']); assert.strictEqual(schema?.properties.thinkingLevel?.default, 'medium'); - assert.strictEqual(schema?.properties.contextTier, undefined); + assert.strictEqual(schema?.properties.contextSize, undefined); } finally { await disposeAgent(agent); } }); - test('configSchema emits a numeric contextTier property when long_context tier exceeds default', async () => { + test('configSchema emits a numeric contextSize property when long_context tier exceeds default', async () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [{ id: 'claude-sonnet', @@ -936,17 +936,17 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const models = await waitForState(agent.models, models => models.length > 0); - const contextTier = models[0].configSchema?.properties.contextTier; - assert.strictEqual(contextTier?.type, 'number'); - assert.deepStrictEqual(contextTier?.enum, [200_000, 1_000_000]); - assert.strictEqual(contextTier?.default, 200_000); - assert.deepStrictEqual(contextTier?.enumLabels, ['200K', '1M']); + const contextSize = models[0].configSchema?.properties.contextSize; + assert.strictEqual(contextSize?.type, 'number'); + assert.deepStrictEqual(contextSize?.enum, [200_000, 1_000_000]); + assert.strictEqual(contextSize?.default, 200_000); + assert.deepStrictEqual(contextSize?.enumLabels, ['200K', '1M']); } finally { await disposeAgent(agent); } }); - test('configSchema omits contextTier when long_context tier is missing or not larger', async () => { + test('configSchema omits contextSize when long_context tier is missing or not larger', async () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [ { @@ -975,7 +975,7 @@ suite('CopilotAgent', () => { } }); - suite('contextTier resolution', () => { + suite('contextSize to contextTier mapping', () => { const longContextModel: ITestCopilotModelInfo = { id: 'claude-sonnet', name: 'Claude Sonnet', @@ -1013,28 +1013,28 @@ suite('CopilotAgent', () => { } } - test('maps the largest numeric context window to long_context', async () => { - const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextTier: '1000000' } }, [longContextModel]); + test('maps the largest numeric context size to long_context', async () => { + const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextSize: '1000000' } }, [longContextModel]); assert.ok(config, 'SDK createSession should be called during materialization'); assert.strictEqual(config.contextTier, 'long_context'); }); - test('maps the default numeric context window to default', async () => { - const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextTier: '200000' } }, [longContextModel]); + test('maps the default numeric context size to default', async () => { + const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextSize: '200000' } }, [longContextModel]); assert.ok(config); assert.strictEqual(config.contextTier, 'default'); }); - test('drops a numeric context window the model does not offer', async () => { + test('drops a numeric context size the model does not offer', async () => { const config = await captureSessionConfig( - { id: 'no-context-picker', config: { contextTier: '1000000' } }, + { id: 'no-context-picker', config: { contextSize: '1000000' } }, [{ id: 'no-context-picker', name: 'No Picker' }], ); assert.ok(config); assert.strictEqual(config.contextTier, undefined); }); - test('passes through an already-resolved tier string', async () => { + test('passes through a legacy resolved tier string under the deprecated contextTier key', async () => { const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextTier: 'long_context' } }, [longContextModel]); assert.ok(config); assert.strictEqual(config.contextTier, 'long_context'); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts index 76739a8cdd925..f5593a8b8ed3c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -122,7 +122,7 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu private static _groupForConfigKey(key: string): string | undefined { switch (key) { case 'thinkingLevel': return 'navigation'; - case 'contextTier': return 'tokens'; + case 'contextSize': return 'tokens'; default: return undefined; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index ee9e6e47bbdea..dca5230ba065c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -2960,10 +2960,22 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return undefined; } + // Config values travel as strings. Most pickers already produce strings, but a synthesized + // numeric picker (e.g. the context-size picker, whose enum values are token counts) hands back a + // number. Coerce numeric/boolean values to strings only when the model's configuration schema + // declares that property, so the selection survives into the string `config` bag (and is mapped + // to the SDK context tier by the agent's `getCopilotContextTier`) while stray untyped values are + // still ignored. + const schemaProperties = languageModelIdentifier + ? this._languageModelsService.lookupLanguageModel(languageModelIdentifier)?.configurationSchema?.properties + : undefined; + const config: Record = {}; for (const [key, value] of Object.entries(modelConfiguration ?? {})) { if (typeof value === 'string') { config[key] = value; + } else if ((typeof value === 'number' || typeof value === 'boolean') && schemaProperties?.[key]) { + config[key] = String(value); } } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 375b2b370cedd..5a5fd1b0512b6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -1889,6 +1889,32 @@ suite('AgentHostChatContribution', () => { assert.deepStrictEqual(agentHostService.createSessionCalls[0].model, { id: 'claude-sonnet-4-20250514', config: { thinkingLevel: 'high' } }); })); + test('coerces a schema-declared numeric model configuration (context size) to a string', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const languageModels = new Map([ + ['agent-host-copilot:claude-sonnet-4-20250514', upcastPartial({ + name: 'Claude Sonnet', + configurationSchema: { + type: 'object', + properties: { + contextSize: { type: 'number', enum: [200000, 1000000], default: 200000 }, + }, + }, + })], + ]); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables, { languageModels }); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'Hi', + userSelectedModelId: 'agent-host-copilot:claude-sonnet-4-20250514', + modelConfiguration: { contextSize: 1000000, ignored: 1 }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.createSessionCalls.length, 1); + assert.deepStrictEqual(agentHostService.createSessionCalls[0].model, { id: 'claude-sonnet-4-20250514', config: { contextSize: '1000000' } }); + })); + test('passes model id as-is when no vendor prefix', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); From 891fa0084a458e131223a1496a4c8fc526238257 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 23 Jun 2026 14:27:27 -0700 Subject: [PATCH 09/18] update types --- src/vs/platform/agentHost/common/state/protocol/.ahp-version | 2 +- .../platform/agentHost/common/state/protocol/common/state.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index a486d1ec6eb20..9a13bc820f990 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -77c6312 +151dbad diff --git a/src/vs/platform/agentHost/common/state/protocol/common/state.ts b/src/vs/platform/agentHost/common/state/protocol/common/state.ts index 578c4ccc1948a..32500adb19665 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/state.ts @@ -27,6 +27,9 @@ export type URI = string; */ export type StringOrMarkdown = string | { markdown: string }; +/** A primitive JSON value: a string, number, boolean, or `null`. */ +export type JsonPrimitive = string | number | boolean | null; + // ─── Icon ──────────────────────────────────────────────────────────────────── /** @@ -162,7 +165,7 @@ export interface ConfigPropertySchema { /** JSON Schema: default value */ default?: unknown; /** JSON Schema: allowed values. May be primitives of any JSON type. */ - enum?: (string | number | boolean | null)[]; + enum?: JsonPrimitive[]; /** Display extension: human-readable label per enum value (parallel array) */ enumLabels?: string[]; /** Display extension: description per enum value (parallel array) */ From f47c3197b0c575bf27ec723f0809a0bea1896ef6 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 23 Jun 2026 15:15:12 -0700 Subject: [PATCH 10/18] PR --- .../platform/agentHost/common/agentService.ts | 2 ++ .../agentHost/node/agentSideEffects.ts | 2 ++ .../agentHost/node/claude/claudeAgent.ts | 2 ++ .../agentHost/node/codex/codexAgent.ts | 2 ++ .../agentHost/node/copilot/copilotAgent.ts | 2 ++ .../agentHostLanguageModelProvider.ts | 10 ++++-- .../browser/widget/input/chatInputPart.ts | 2 ++ .../viewPane/chatContextUsageWidget.ts | 36 ++++++++++++++++--- 8 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 3de8693ee6d39..a8dd42c2ed4b3 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -673,6 +673,8 @@ export interface IAgentModelInfo { readonly id: string; readonly name: string; readonly maxContextWindow?: number; + readonly maxOutputTokens?: number; + readonly maxPromptTokens?: number; readonly supportsVision: boolean; readonly configSchema?: ConfigSchema; readonly policyState?: PolicyState; diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 0cb3f7fa50f59..dc05f8e55f8a9 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -176,6 +176,8 @@ export class AgentSideEffects extends Disposable { provider: m.provider, name: m.name, maxContextWindow: m.maxContextWindow, + maxOutputTokens: m.maxOutputTokens, + maxPromptTokens: m.maxPromptTokens, supportsVision: m.supportsVision, policyState: m.policyState, configSchema: m.configSchema, diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index d2696be4cffd9..a8b2d6473c955 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -102,6 +102,8 @@ function toAgentModelInfo(m: CCAModel, provider: AgentProvider): IAgentModelInfo id: m.id, name: m.name, maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, + maxOutputTokens: m.capabilities?.limits?.max_output_tokens, + maxPromptTokens: m.capabilities?.limits?.max_prompt_tokens, supportsVision: !!supports?.vision, ...(configSchema ? { configSchema } : {}), ...(policyState ? { policyState } : {}), diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 9b2f5ed3a15aa..97ca749dee153 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -740,6 +740,8 @@ export class CodexAgent extends Disposable implements IAgent { id: m.id, name: m.name ?? m.id, maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, + maxOutputTokens: m.capabilities?.limits?.max_output_tokens, + maxPromptTokens: m.capabilities?.limits?.max_prompt_tokens, supportsVision: !!m.capabilities?.supports?.vision, configSchema, policyState: m.policy?.state as PolicyState | undefined, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 50ff2e50d0677..d99631bdc4545 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -955,6 +955,8 @@ export class CopilotAgent extends Disposable implements IAgent { // Synthetic SDK entries like `auto` ship with `capabilities: {}` and // no fixed context window — surface them with maxContextWindow undefined. maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, + maxOutputTokens: m.capabilities?.limits?.max_output_tokens, + maxPromptTokens: m.capabilities?.limits?.max_prompt_tokens, supportsVision: !!m.capabilities?.supports?.vision, configSchema: this._createModelConfigSchema(m), policyState: m.policy?.state as PolicyState | undefined, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts index f5593a8b8ed3c..6bdedcbf1211f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -72,8 +72,14 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu vendor: this._vendor, version: '1.0', family: m.id, - maxInputTokens: m.maxContextWindow ?? 0, - maxOutputTokens: 0, + // `maxContextWindow` (SDK `max_context_window_tokens`) is the FULL + // window (prompt + output); the prompt-only budget is + // `maxPromptTokens` (SDK `max_prompt_tokens`). Use the prompt budget + // for input so input + output is close to the full window instead of + // double-counting output. Fall back to the full window when the + // prompt budget is unavailable (e.g. synthetic `auto` models). + maxInputTokens: m.maxPromptTokens ?? m.maxContextWindow ?? 0, + maxOutputTokens: m.maxOutputTokens ?? 0, isDefaultForLocation: {}, isUserSelectable: true, pricing: multiplierNumeric !== undefined ? `${multiplierNumeric}x` : undefined, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index d8f0a682a9ea1..0766aa8123207 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -816,6 +816,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._register(autorun(reader => { const lm = this._currentLanguageModel.read(reader); this.chatModelIdKey.set(lm?.metadata.id.toLowerCase() ?? ''); + this.contextUsageWidget?.setSelectedModel(lm?.identifier); if (lm?.metadata.name) { this.accessibilityService.alert(lm.metadata.name); } @@ -2705,6 +2706,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Context usage widget — will be positioned in the toolbar after toolbars are created this.contextUsageWidget = this._register(this.instantiationService.createInstance(ChatContextUsageWidget)); this.contextUsageWidget.setChatWidget(widget); + this.contextUsageWidget.setSelectedModel(this._currentLanguageModel.get()?.identifier); this.contextUsageWidget.setModelConfigurationResolver( modelId => this.getModelConfiguration(modelId), this._modelConfigStore.onDidChange, diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts index 5f902a8ebb121..e978ba71cb658 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -103,6 +103,13 @@ export class ChatContextUsageWidget extends Disposable { private readonly _modelConfigurationListener = this._register(new MutableDisposable()); private _currentResponse: IChatResponseModel | undefined; private _currentModelId: string | undefined; + /** + * The model the user currently has selected in the picker. When set it + * overrides the last request's model for computing the context-window + * denominator, so switching models updates the widget before the next + * request is sent. The usage numerator still comes from the last response. + */ + private _selectedModelId: string | undefined; private _sessionCost: number = 0; private readonly _hoverDisposable = this._register(new MutableDisposable()); private readonly _contextUsageDetails = this._register(new MutableDisposable()); @@ -299,21 +306,42 @@ export class ChatContextUsageWidget extends Disposable { ): void { this._modelConfigurationResolver = resolver; this._modelConfigurationListener.value = onDidChange(modelId => { - if (this._currentResponse && this._currentModelId === modelId) { - this.updateFromResponse(this._currentResponse, modelId); + if (this._currentResponse && this._currentModelId && (this._currentModelId === modelId || this._selectedModelId === modelId)) { + this.updateFromResponse(this._currentResponse, this._currentModelId); } }); } + /** + * Sets the model the user currently has selected in the picker. The + * context-window denominator then reflects this model immediately, even + * before a request is sent with it. The usage numerator still comes from the + * last completed response. + */ + setSelectedModel(modelId: string | undefined): void { + if (this._selectedModelId === modelId) { + return; + } + this._selectedModelId = modelId; + if (this._currentResponse && this._currentModelId) { + this.updateFromResponse(this._currentResponse, this._currentModelId); + } + } + private updateFromResponse(response: IChatResponseModel, modelId: string): void { const usage = response.usage; - const modelMetadata = this.languageModelsService.lookupLanguageModel(modelId); - const modelConfiguration = this._modelConfigurationResolver?.(modelId) ?? this.languageModelsService.getModelConfiguration(modelId); + // The denominator (context window) follows the currently selected model so + // switching models updates the widget immediately; the numerator (usage) + // still comes from the last response. + const denominatorModelId = this._selectedModelId ?? modelId; + const modelMetadata = this.languageModelsService.lookupLanguageModel(denominatorModelId); + const modelConfiguration = this._modelConfigurationResolver?.(denominatorModelId) ?? this.languageModelsService.getModelConfiguration(denominatorModelId); const configuredContextSize = typeof modelConfiguration?.contextSize === 'number' ? modelConfiguration.contextSize : undefined; const maxInputTokens = configuredContextSize ?? modelMetadata?.maxInputTokens; const maxOutputTokens = modelMetadata?.maxOutputTokens; const totalContextWindow = (maxInputTokens ?? 0) + (maxOutputTokens ?? 0); + if (!usage || totalContextWindow <= 0) { if (!this.currentData) { this.hide(); From 186d739efa3845e992bbf86d85e981398d0b088e Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 23 Jun 2026 15:20:31 -0700 Subject: [PATCH 11/18] sync --- src/vs/platform/agentHost/common/state/protocol/.ahp-version | 2 +- .../agentHost/common/state/protocol/channels-root/state.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 9a13bc820f990..02b9c525c4fa8 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -151dbad +53efd24 diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index e9d897bde9ba3..17c589f8a4d57 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -97,6 +97,10 @@ export interface SessionModelInfo { name: string; /** Maximum context window size */ maxContextWindow?: number; + /** Maximum number of output tokens the model can generate */ + maxOutputTokens?: number; + /** Maximum number of prompt (input) tokens the model accepts */ + maxPromptTokens?: number; /** Whether the model supports vision */ supportsVision?: boolean; /** Policy configuration state */ From b5a8a55570ad1cb33401f20b030908063559d40f Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 23 Jun 2026 15:36:50 -0700 Subject: [PATCH 12/18] cleanup --- .../common/state/protocol/.ahp-version | 2 +- .../state/protocol/channels-root/state.ts | 10 ++++--- .../node/copilot/copilotSessionLauncher.ts | 4 +-- .../agentHostLanguageModelProvider.ts | 8 +----- .../agentHost/agentHostSessionHandler.ts | 21 +++++---------- .../agentHostChatContribution.test.ts | 26 ------------------- 6 files changed, 18 insertions(+), 53 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 02b9c525c4fa8..164ae7efcb6b3 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -53efd24 +70c7737 diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index 17c589f8a4d57..e427455085531 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { ConfigSchema, ProtectedResourceMetadata } from '../common/state.js'; +import type { ConfigSchema, JsonPrimitive, ProtectedResourceMetadata } from '../common/state.js'; import type { TerminalInfo } from '../channels-terminal/state.js'; import type { Customization } from '../channels-session/state.js'; @@ -130,8 +130,12 @@ export interface SessionModelInfo { export interface ModelSelection { /** Model identifier */ id: string; - /** Model-specific configuration values */ - config?: Record; + /** + * Model-specific configuration values. Values are JSON primitives: most + * pickers produce strings, but some (e.g. a numeric context-size picker) + * produce numbers or booleans, which are carried through as-is. + */ + config?: Record; } // ─── Root Config Types ─────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index c54d6aa4cd0df..f59cf3abd2444 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -132,11 +132,11 @@ export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBa export type CopilotSessionLaunchPlan = ICopilotCreateSessionLaunchPlan | ICopilotResumeSessionLaunchPlan; -function isReasoningEffort(value: string | undefined): value is ReasoningEffort { +function isReasoningEffort(value: unknown): value is ReasoningEffort { return ReasoningEfforts.some(reasoningEffort => reasoningEffort === value); } -export function isContextTier(value: string | undefined): value is ContextTier { +export function isContextTier(value: unknown): value is ContextTier { return ContextTiers.some(contextTier => contextTier === value); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts index 6bdedcbf1211f..ff6181f17c242 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -72,13 +72,7 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu vendor: this._vendor, version: '1.0', family: m.id, - // `maxContextWindow` (SDK `max_context_window_tokens`) is the FULL - // window (prompt + output); the prompt-only budget is - // `maxPromptTokens` (SDK `max_prompt_tokens`). Use the prompt budget - // for input so input + output is close to the full window instead of - // double-counting output. Fall back to the full window when the - // prompt budget is unavailable (e.g. synthetic `auto` models). - maxInputTokens: m.maxPromptTokens ?? m.maxContextWindow ?? 0, + maxInputTokens: m.maxPromptTokens ?? 0, maxOutputTokens: m.maxOutputTokens ?? 0, isDefaultForLocation: {}, isUserSelectable: true, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index dca5230ba065c..5f0b7171f5d0d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -2960,22 +2960,15 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return undefined; } - // Config values travel as strings. Most pickers already produce strings, but a synthesized - // numeric picker (e.g. the context-size picker, whose enum values are token counts) hands back a - // number. Coerce numeric/boolean values to strings only when the model's configuration schema - // declares that property, so the selection survives into the string `config` bag (and is mapped - // to the SDK context tier by the agent's `getCopilotContextTier`) while stray untyped values are - // still ignored. - const schemaProperties = languageModelIdentifier - ? this._languageModelsService.lookupLanguageModel(languageModelIdentifier)?.configurationSchema?.properties - : undefined; - - const config: Record = {}; + // Forward model-specific config values as-is. Most pickers produce strings, + // but a synthesized numeric picker (e.g. the context-size picker, whose enum + // values are token counts) hands back a number; the protocol `config` bag + // carries JSON primitives, so the selection survives into it (and is mapped + // to the SDK context tier by the agent's `getCopilotContextTier`). + const config: Record = {}; for (const [key, value] of Object.entries(modelConfiguration ?? {})) { - if (typeof value === 'string') { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { config[key] = value; - } else if ((typeof value === 'number' || typeof value === 'boolean') && schemaProperties?.[key]) { - config[key] = String(value); } } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 5a5fd1b0512b6..375b2b370cedd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -1889,32 +1889,6 @@ suite('AgentHostChatContribution', () => { assert.deepStrictEqual(agentHostService.createSessionCalls[0].model, { id: 'claude-sonnet-4-20250514', config: { thinkingLevel: 'high' } }); })); - test('coerces a schema-declared numeric model configuration (context size) to a string', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const languageModels = new Map([ - ['agent-host-copilot:claude-sonnet-4-20250514', upcastPartial({ - name: 'Claude Sonnet', - configurationSchema: { - type: 'object', - properties: { - contextSize: { type: 'number', enum: [200000, 1000000], default: 200000 }, - }, - }, - })], - ]); - const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables, { languageModels }); - - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { - message: 'Hi', - userSelectedModelId: 'agent-host-copilot:claude-sonnet-4-20250514', - modelConfiguration: { contextSize: 1000000, ignored: 1 }, - }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); - await turnPromise; - - assert.strictEqual(agentHostService.createSessionCalls.length, 1); - assert.deepStrictEqual(agentHostService.createSessionCalls[0].model, { id: 'claude-sonnet-4-20250514', config: { contextSize: '1000000' } }); - })); - test('passes model id as-is when no vendor prefix', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); From 9700f87ab3ab4fb4f3034da69fd3e4db437fdcbe Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 23 Jun 2026 15:47:32 -0700 Subject: [PATCH 13/18] minor clean --- .../agentSessions/agentHost/agentHostSessionHandler.ts | 6 +++--- .../browser/widgetHosts/viewPane/chatContextUsageWidget.ts | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 5f0b7171f5d0d..15cb4f938f4ae 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -28,7 +28,7 @@ import { SessionConfigKey } from '../../../../../../platform/agentHost/common/se import { IAgentSubscription, observableFromSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ChatTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionItem as AhpCompletionItem } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { ConfirmationOptionKind, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ConfirmationOptionKind, JsonPrimitive, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, ChatTurnStartedAction, isChatAction, type ChatAction, type ClientChatAction, type ClientSessionAction, type ChatInputCompletedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { buildSubagentSessionUri, getToolSubagentContent, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, buildChatUri, buildDefaultChatUri, parseChatUri, mergeSessionWithDefaultChat, type ChatState, type ISessionWithDefaultChat, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -2965,9 +2965,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // values are token counts) hands back a number; the protocol `config` bag // carries JSON primitives, so the selection survives into it (and is mapped // to the SDK context tier by the agent's `getCopilotContextTier`). - const config: Record = {}; + const config: Record = {}; for (const [key, value] of Object.entries(modelConfiguration ?? {})) { - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value === null) { config[key] = value; } } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts index e978ba71cb658..20a8a341983f8 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -341,7 +341,6 @@ export class ChatContextUsageWidget extends Disposable { const maxOutputTokens = modelMetadata?.maxOutputTokens; const totalContextWindow = (maxInputTokens ?? 0) + (maxOutputTokens ?? 0); - if (!usage || totalContextWindow <= 0) { if (!this.currentData) { this.hide(); From d42f1cba06d7877baf8921e2db1550cb68517d0c Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 23 Jun 2026 15:49:41 -0700 Subject: [PATCH 14/18] sync --- src/vs/platform/agentHost/common/state/protocol/.ahp-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 164ae7efcb6b3..da9a1e30e4cb3 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -70c7737 +dfd18a9 From 84b6c0ef32255484b10f46ac1fc5b4d097e8df37 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 23 Jun 2026 15:58:00 -0700 Subject: [PATCH 15/18] minor readability --- .../browser/widgetHosts/viewPane/chatContextUsageWidget.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts index 0658929d5e556..94a313ed57072 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -306,7 +306,8 @@ export class ChatContextUsageWidget extends Disposable { ): void { this._modelConfigurationResolver = resolver; this._modelConfigurationListener.value = onDidChange(modelId => { - if (this._currentResponse && this._currentModelId && (this._currentModelId === modelId || this._selectedModelId === modelId)) { + const affectsDisplayedModel = this._currentModelId === modelId || this._selectedModelId === modelId; + if (this._currentResponse && this._currentModelId && affectsDisplayedModel) { this.updateFromResponse(this._currentResponse, this._currentModelId); } }); From eb62eb0c08f18476520083375c84e96d752b22e1 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 23 Jun 2026 16:18:09 -0700 Subject: [PATCH 16/18] fix tests --- .../agentHost/test/node/agentSideEffects.test.ts | 4 +++- .../agentHost/test/node/claudeAgent.test.ts | 4 ++-- .../agentHost/test/node/copilotAgent.test.ts | 13 ++++++++++--- .../agentSessions/agentHostChatContribution.test.ts | 6 +++--- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 01a10fbcedb89..ddeac7901778a 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -707,7 +707,7 @@ suite('AgentSideEffects', () => { } return e.action.agents[0]?.models.length === 1; })); - agent.setModels([{ provider: 'mock', id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false }]); + agent.setModels([{ provider: 'mock', id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, maxOutputTokens: 16000, maxPromptTokens: 112000, supportsVision: false }]); await envelope; const actions = envelopes.map(e => e.action).filter(action => action.type === ActionType.RootAgentsChanged); @@ -718,6 +718,8 @@ suite('AgentSideEffects', () => { provider: 'mock', name: 'mock Model', maxContextWindow: 128000, + maxOutputTokens: 16000, + maxPromptTokens: 112000, supportsVision: false, policyState: undefined, configSchema: undefined, diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 63a299aaf01fa..aafcfea8bbad2 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -786,8 +786,8 @@ suite('ClaudeAgent', () => { accepted: true, startCalls: ['tok'], models: [ - { provider: 'claude', id: 'claude-opus-4.6', name: 'Claude Opus 4.6', maxContextWindow: 200_000, supportsVision: false, policyState: 'enabled', _meta: { multiplierNumeric: 1 } }, - { provider: 'claude', id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6', maxContextWindow: 200_000, supportsVision: false, policyState: 'enabled', _meta: { multiplierNumeric: 1 } }, + { provider: 'claude', id: 'claude-opus-4.6', name: 'Claude Opus 4.6', maxContextWindow: 200_000, maxOutputTokens: 8192, maxPromptTokens: 200_000, supportsVision: false, policyState: 'enabled', _meta: { multiplierNumeric: 1 } }, + { provider: 'claude', id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6', maxContextWindow: 200_000, maxOutputTokens: 8192, maxPromptTokens: 200_000, supportsVision: false, policyState: 'enabled', _meta: { multiplierNumeric: 1 } }, ], }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 3a9f233d72fae..f3b48d5ad186a 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -194,7 +194,7 @@ interface ITestCopilotModelInfo { readonly name: string; readonly capabilities?: { readonly supports?: { readonly vision?: boolean }; - readonly limits?: { readonly max_context_window_tokens?: number }; + readonly limits?: { readonly max_context_window_tokens?: number; readonly max_output_tokens?: number; readonly max_prompt_tokens?: number }; }; readonly policy?: { readonly state?: NonNullable['state'] }; readonly billing?: ModelInfo['billing'] & { @@ -231,7 +231,12 @@ function toSdkModelInfo(model: ITestCopilotModelInfo): ModelInfo { }, limits: { max_context_window_tokens: model.capabilities?.limits?.max_context_window_tokens ?? 0, - }, + // `max_output_tokens` is present on the RPC `models.list` shape the + // agent reads but absent from the SDK's `ModelInfo` limits type, so + // widen here to let fixtures exercise the real value. + max_output_tokens: model.capabilities?.limits?.max_output_tokens, + max_prompt_tokens: model.capabilities?.limits?.max_prompt_tokens, + } as ModelInfo['capabilities']['limits'], }, ...(model.policy ? { policy: { state: model.policy.state ?? 'enabled', terms: '' } } : {}), ...(model.billing ? { billing: model.billing } : {}), @@ -954,7 +959,7 @@ suite('CopilotAgent', () => { id: 'gpt-4o', name: 'GPT-4o', billing: { multiplier: 1.5 }, - capabilities: { limits: { max_context_window_tokens: 128000 }, supports: { vision: true } }, + capabilities: { limits: { max_context_window_tokens: 128000, max_output_tokens: 16000, max_prompt_tokens: 112000 }, supports: { vision: true } }, }]), }); try { @@ -966,6 +971,8 @@ suite('CopilotAgent', () => { id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128000, + maxOutputTokens: 16000, + maxPromptTokens: 112000, supportsVision: true, configSchema: undefined, policyState: undefined, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 4540c7fd87a66..048cf84450dfe 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -1880,13 +1880,13 @@ suite('AgentHostChatContribution', () => { const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'Hi', userSelectedModelId: 'agent-host-copilot:claude-sonnet-4-20250514', - modelConfiguration: { thinkingLevel: 'high', ignored: 1 }, + modelConfiguration: { thinkingLevel: 'high', contextSize: 272000 }, }); fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); - assert.deepStrictEqual(agentHostService.createSessionCalls[0].model, { id: 'claude-sonnet-4-20250514', config: { thinkingLevel: 'high' } }); + assert.deepStrictEqual(agentHostService.createSessionCalls[0].model, { id: 'claude-sonnet-4-20250514', config: { thinkingLevel: 'high', contextSize: 272000 } }); })); test('passes model id as-is when no vendor prefix', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -3684,7 +3684,7 @@ suite('AgentHostChatContribution', () => { test('maps models with correct metadata', async () => { const provider = disposables.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot')); provider.updateModels([ - { provider: 'copilot', id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128000, supportsVision: true, _meta: { multiplierNumeric: 1.5 } }, + { provider: 'copilot', id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128000, maxPromptTokens: 128000, supportsVision: true, _meta: { multiplierNumeric: 1.5 } }, ]); const models = await provider.provideLanguageModelChatInfo({}, CancellationToken.None); From 940eeea93227293505ec9054d2cb54b4feae0f7f Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 24 Jun 2026 12:59:24 -0700 Subject: [PATCH 17/18] Update .ahp-version file --- src/vs/platform/agentHost/common/state/protocol/.ahp-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 007b1ac3f4d7b..ec859badb2194 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -0259a7e \ No newline at end of file +0259a7e From 2f248f20539b233814f8dc330af6dd287a4771cf Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Wed, 24 Jun 2026 13:27:40 -0700 Subject: [PATCH 18/18] nit --- .../platform/agentHost/node/copilot/copilotSessionLauncher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 4b62a8c1b58f9..dea8682fbb1c9 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -148,7 +148,7 @@ function isReasoningEffort(value: unknown): value is ReasoningEffort { return ReasoningEfforts.some(reasoningEffort => reasoningEffort === value); } -export function isContextTier(value: unknown): value is ContextTier { +function isContextTier(value: unknown): value is ContextTier { return ContextTiers.some(contextTier => contextTier === value); }