From 2b666c3d35566c60edafc5050d23cf2ce7ea1e95 Mon Sep 17 00:00:00 2001 From: nikita-ashihmin Date: Thu, 10 Sep 2026 01:10:31 +0400 Subject: [PATCH] feat: advertise recommended config values (#1111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clients that negotiate the AIR `recommendedValue` capability receive concrete model and effort choices instead of ambiguous default rows. The SDK's default model is mapped only to an exact selectable model (including equivalent context suffix spellings); unavailable recommendations retain the legacy default. Recommendations remain independent of explicit selections. The adapter applies the displayed effort to the SDK at startup and on model switches, preserving supported user pins and re-reading per-model settings for automatic choices. Permission modes and clients without the capability retain their existing configuration behavior. Model and effort configuration logic is extracted into dedicated modules. Terse SDK model labels are enriched with concrete versions from model metadata: for example, `Fable` becomes `Fable 5.1`, `Sonnet` becomes `Sonnet 5`, and `Opus (1M context)` becomes `Opus 5`. Custom names are preserved, and collisions fall back to the original labels. An SDK version guard requires review on upgrades, and an opt-in live initialization test verifies the upstream model contract without sending a prompt. Validation: - `npm run check` - `npm run build` - `env -u ANTHROPIC_BASE_URL npm run test:run` — 1253 passed, 28 skipped - `RUN_INTEGRATION_TESTS=true npx vitest run src/tests/model-presentation.test.ts` — 4 passed - Regression coverage for unavailable model generations/context windows, startup effort, per-model settings, unsupported effort pins, and duplicate Opus labels --- README.md | 1 + docs/recommended-config-values-extension.md | 118 +++ src/acp-agent.ts | 756 +++++--------------- src/air-extension.ts | 1 + src/clear-context-coordinator.ts | 10 +- src/session-effort.ts | 118 +++ src/session-model.ts | 466 ++++++++++++ src/tests/acp-agent-settings.test.ts | 112 +++ src/tests/acp-agent.test.ts | 2 + src/tests/clear-context-coordinator.test.ts | 36 + src/tests/model-presentation.test.ts | 212 ++++++ src/tests/model-resolution.test.ts | 18 +- src/tests/recommended-config-values.test.ts | 282 ++++++++ src/tests/session-config-options.test.ts | 211 +++++- 14 files changed, 1767 insertions(+), 576 deletions(-) create mode 100644 docs/recommended-config-values-extension.md create mode 100644 src/session-effort.ts create mode 100644 src/session-model.ts create mode 100644 src/tests/model-presentation.test.ts create mode 100644 src/tests/recommended-config-values.test.ts diff --git a/README.md b/README.md index db427477..2c9a69de 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https - Client MCP servers - Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md) - Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/session-failure-extension.md) +- Concrete model and effort defaults through the opt-in [recommended config value extension](docs/recommended-config-values-extension.md) - Tool permission presentation, editable choices, and durable effects through the [permission extension](docs/permission-extension.md) Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/). diff --git a/docs/recommended-config-values-extension.md b/docs/recommended-config-values-extension.md new file mode 100644 index 00000000..f3fa3b42 --- /dev/null +++ b/docs/recommended-config-values-extension.md @@ -0,0 +1,118 @@ +# Recommended config values extension + +This document defines the experimental AIR `recommendedValue` session configuration extension +implemented by `claude-agent-acp`. It lets a client show concrete model and effort choices without +an ambiguous `default` row while preserving legacy config options for every client that does not +opt in. + +Permission modes and all other session config options are outside this extension and remain +unchanged. + +## Capability negotiation + +A client opts in through the common AIR capability list in `initialize`: + +```json +{ + "clientCapabilities": { + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "capabilities": ["recommendedValue"] + } + } + } + } +} +``` + +The adapter enables the extension only when the AIR version is a finite integer greater than or +equal to `1` and `capabilities` contains the singular `recommendedValue` capability. Otherwise it +preserves the existing `default` rows, current values, and metadata exactly. The adapter advertises +the same capability in its top-level initialize-response `_meta.jetbrains.air.capabilities` list. + +## Config option metadata + +For each selector transformed by the extension, the adapter writes the concrete recommendation +through the common AIR metadata envelope at `_meta.jetbrains.air.recommendedValue`: + +```json +{ + "id": "model", + "type": "select", + "currentValue": "sonnet", + "options": [ + { "value": "opus", "name": "Claude Opus" }, + { "value": "sonnet", "name": "Claude Sonnet" }, + { "value": "haiku", "name": "Claude Haiku" } + ], + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "recommendedValue": "sonnet" + } + } + } +} +``` + +`recommendedValue` always names one of that selector's advertised option values. It is independent +from `currentValue`: a user's explicit selection remains current even when the SDK recommends a +different value. + +## Model behavior + +The SDK's `default` model entry may carry a `resolvedModel` identifying the model currently +recommended by Claude. The adapter matches that identifier exactly to a selectable named model +(accepting equivalent `-1m` and `[1m]` suffix spellings), +removes the `default` row, and advertises the named value in +`_meta.jetbrains.air.recommendedValue`. When the session itself is still using the SDK default, the +same concrete value is presented as `currentValue`. + +If the SDK recommendation cannot be mapped to an advertised named model, the adapter retains the +legacy `default` row for that selector and omits `recommendedValue`. This prevents a client from +receiving a recommendation or current value it cannot select. + +Terse SDK labels that match their underlying model family include the concrete version derived +from model metadata: for example, `Sonnet` becomes `Sonnet 5`, `Fable` becomes `Fable 5.1`, and +`Claude Haiku` becomes `Claude Haiku 4.5`. +Context suffixes such as `Opus (1M context)` are omitted from the label because the context remains +in the description. Custom labels are preserved, and normalization falls back to the original +labels if two selectable entries would otherwise collide. This presentation also applies to +legacy clients. + +## Effort behavior + +When the current model supports effort selection, the adapter removes the `default` effort row and +advertises `medium` as the recommendation. An existing explicit or settings-derived effort remains +the `currentValue`; an absent or legacy `default` current effort is presented as `medium`. + +The adapter applies the displayed effort to the SDK on session creation and model switches, so +the concrete selection reflects the effort actually used. Explicit SDK `options.effort` and ACP +picker choices remain pinned across model switches while supported. Otherwise each switch +re-reads the new model's settings before falling back to the recommendation. Switching to a +model without effort support clears the flag override. Legacy clients continue to let the SDK +resolve automatic effort. + +If effort synchronization fails after the model has already switched, the adapter still reports +the new model but never presents the unapplied effort as current. It retains the last successfully +applied value when that value is selectable for the new model; otherwise it temporarily omits the +effort selector until a later successful switch can rebuild truthful state. + +If a future model exposes effort choices without `medium`, the first SDK-advertised effort level is +used so both `recommendedValue` and `currentValue` remain valid option values. + +## SDK upgrade check + +The ordinary test suite pins the SDK version whose Opus label was verified. When updating the +SDK, first run the live contract test in an authenticated environment: + +```sh +RUN_INTEGRATION_TESTS=true npx vitest run src/tests/model-presentation.test.ts +``` + +Review any changes to the available Opus entries and their labels before updating the version +guard and, if necessary, the normalization. The live test only initializes the SDK; it sends no +model prompt. diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 38b9808b..a3802c94 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -59,7 +59,6 @@ import { AgentInfo, CanUseTool, deleteSession, - EffortLevel, FastModeDisabledReason, FastModeState, getSessionMessages, @@ -112,7 +111,12 @@ import { NativeSubagent, NativeSubagentRuntime, } from "./native-subagents.js"; -import { AIR_ASYNC_TASKS_CAPABILITY, withAirMeta } from "./air-extension.js"; +import { + AIR_ASYNC_TASKS_CAPABILITY, + AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, + clientSupportsAirCapability, + withAirMeta, +} from "./air-extension.js"; import { AsyncTaskRuntime, backgroundBashTaskFromToolResult, @@ -239,12 +243,35 @@ import { exitPlanModeRawOutput, observeExitPlanToolResults, } from "./exit-plan.js"; -import { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js"; +import { DEFAULT_AGENT_ID } from "./session-config-ids.js"; import { parseToolResultMeta } from "./tool-result-meta.js"; import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "./usage-markdown.js"; -export { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js"; +export { DEFAULT_AGENT_ID } from "./session-config-ids.js"; import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js"; +import { + applyAvailableModelsAllowlist, + buildModelConfigOption, + getAvailableModels, + MODEL_CONFIG_ID, + resolveModelPreference, + type SessionModelState, +} from "./session-model.js"; +import { + buildEffortConfigOption, + EFFORT_CONFIG_ID, + mergeEffortSettings, + settingsEffortForModel, + toSdkEffortLevel, +} from "./session-effort.js"; + +export { EFFORT_CONFIG_ID, settingsEffortForModel } from "./session-effort.js"; +export { + applyAvailableModelsAllowlist, + matchResumedModel, + MODEL_CONFIG_ID, + resolveModelPreference, +} from "./session-model.js"; export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); @@ -529,15 +556,6 @@ function parseSteerRequest(params: unknown): SteerRequest { }; } -/** Internal model-selection state. Mirrors the shape the ACP SDK exposed as - * `SessionModelState` before model selection moved entirely into - * `SessionConfigOption` (category "model"). Retained internally to track the - * current model and build the "model" config option. */ -type SessionModelState = { - availableModels: Array<{ modelId: string; name: string; description?: string }>; - currentModelId: string; -}; - /** One in-flight `prompt()` call. A persistent per-session consumer (see * `runConsumer`) drains the SDK query stream for the whole session and settles * each Turn's deferred when that turn's outcome is known, so `prompt()` itself @@ -773,6 +791,9 @@ export type Session = { /** Original ACP parameters used to recreate this query with a new provider. */ creationParams?: NewSessionRequest; settingsManager: SettingsManager; + /** Higher-priority programmatic settings passed to query(). Retained so + * model switches resolve effort from the same effective settings as the SDK. */ + effortSettingsOverride?: Settings; /** This session's title state and the turn-end logic that maintains it. */ titles: SessionTitles; accumulatedUsage: AccumulatedUsage; @@ -805,14 +826,19 @@ export type Session = { * user's intent so it persists across model switches; the Fast mode config * option is only surfaced while the selected model supports it. */ fastModeEnabled: boolean; - /** Whether the user picked a non-default effort through the ACP picker this + /** The non-default effort the user picked through the ACP picker this * session. A pin lives at the SDK's flag layer, which overrides the CLI's * persisted effort (including the per-model `modelSettings` entries), so it - * follows the session across model switches; without one, the CLI resolves - * effort itself and the Effort option is display-only. Cleared when the + * follows the session across model switches. Without a pin, opted-in clients + * apply the settings-derived effort or concrete recommendation on each switch; + * legacy clients leave resolution to the CLI. Cleared when the * user picks "Default" (the flag layer is cleared with it) or when a model * switch clamps the pin away. */ - effortPinnedByUser?: boolean; + effortPinnedLevel?: string; + /** Last concrete effort successfully written to the SDK flag layer. This is + * independent of user ownership: opted-in clients also apply automatic + * recommendations and settings-derived values. */ + appliedEffortLevel?: string; /** Why the SDK currently can't serve Fast mode, when the reason is one worth * telling the user about (see {@link FAST_MODE_UNAVAILABLE_EXPLANATIONS} — * routine states like the SDK's own opt-in requirement normalize to @@ -2100,6 +2126,7 @@ export class ClaudeAcpAgent { AGENT_FILE_CHANGE_REPORT_CAPABILITY, AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, AIR_ASYNC_TASKS_CAPABILITY, + AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, ), steering: { supported: true, @@ -7380,9 +7407,28 @@ export class ClaudeAcpAgent { const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID); const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined; - const seedEffort = session.effortPinnedByUser - ? currentEffort - : settingsEffortForModel(session.settingsManager.getSettings(), newModelInfo, value); + const useRecommendedValue = clientSupportsRecommendedConfigValue(this.clientCapabilities); + const pinnedEffort = session.effortPinnedLevel; + const effortWasPinned = pinnedEffort !== undefined; + const appliedEffortBeforeSwitch = + session.appliedEffortLevel ?? + (useRecommendedValue && typeof currentEffort === "string" && currentEffort !== "default" + ? currentEffort + : pinnedEffort); + const effortPinnedForNewModel = + effortWasPinned && + newModelInfo?.supportsEffort === true && + newModelInfo.supportedEffortLevels?.some((level) => level === pinnedEffort) === true; + const seedEffort = effortPinnedForNewModel + ? pinnedEffort + : settingsEffortForModel( + mergeEffortSettings( + session.settingsManager.getSettings(), + session.effortSettingsOverride, + ), + newModelInfo, + value, + ); session.configOptions = buildConfigOptions( session.modes, session.models, @@ -7399,27 +7445,72 @@ export class ClaudeAcpAgent { useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities), disabledReason: session.fastModeDisabledReason, }, + { + useRecommendedValue, + }, ); - // Sync effort with the SDK only when a user pin changed across the + // Opted-in clients apply the concrete displayed effort on every switch, + // including settings-derived values, so a previous automatic flag cannot + // shadow the new model's settings. This does not create a user pin. + // For legacy clients, sync only when a user pin changed across the // switch — i.e. the new model clamped it away (buildConfigOptions // validated the seed against the new model's levels), where the flag // must be cleared too or the SDK would keep running the old pin // invisibly. Settings-derived seeds are display-only: the CLI resolves // persisted effort itself, and pinning it at the flag layer would // shadow the per-model values on every later switch. - if (session.effortPinnedByUser) { + const shouldSyncEffort = useRecommendedValue || (effortWasPinned && !effortPinnedForNewModel); + if (shouldSyncEffort) { const newEffortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID); const newEffort = typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined; - if (newEffort !== currentEffort) { + try { await session.query.applyFlagSettings({ - effortLevel: toSdkEffortLevel(newEffort), + // A legacy client's unpinned effort is display-only: the CLI + // resolves the persisted value for the new model. When an old + // user pin is no longer supported, clear the flag layer instead + // of replacing it with that displayed value. Opted-in clients + // deliberately apply their concrete displayed effort. + effortLevel: useRecommendedValue ? toSdkEffortLevel(newEffort) : null, }); - if (newEffort === undefined || newEffort === "default") { - session.effortPinnedByUser = false; + session.effortPinnedLevel = effortPinnedForNewModel ? pinnedEffort : undefined; + session.appliedEffortLevel = + useRecommendedValue && newEffort !== "default" ? newEffort : undefined; + } catch (error) { + // setModel has already succeeded. Effort synchronization is a + // secondary, best-effort operation: propagating this error would + // make the RPC report failure (or suppress an external-switch + // notification) even though the SDK is already on the new model. + // Preserve the old SDK/pin bookkeeping and never advertise the + // unapplied value: restore the last applied value when the new model + // can select it, otherwise omit the effort option until a later + // successful switch rebuilds it. + session.effortPinnedLevel = pinnedEffort; + session.appliedEffortLevel = appliedEffortBeforeSwitch; + const appliedValueStillSelectable = + appliedEffortBeforeSwitch !== undefined && + newEffortOpt?.type === "select" && + newEffortOpt.options.some((option) => + "value" in option + ? option.value === appliedEffortBeforeSwitch + : option.options.some((nested) => nested.value === appliedEffortBeforeSwitch), + ); + if (newEffortOpt?.type === "select" && appliedValueStillSelectable) { + newEffortOpt.currentValue = appliedEffortBeforeSwitch; + } else { + session.configOptions = session.configOptions.filter( + (option) => option.id !== EFFORT_CONFIG_ID, + ); } + this.logger.error( + `Failed to synchronize effort after model switch to "${value}":`, + error, + ); } + } else if (effortPinnedForNewModel) { + session.effortPinnedLevel = pinnedEffort; + session.appliedEffortLevel = pinnedEffort; } // Emit current_mode_update only after session.modes AND @@ -7444,19 +7535,24 @@ export class ClaudeAcpAgent { session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o, ); + } else if (configId === EFFORT_CONFIG_ID) { + // Apply first so a rejected control request cannot leave the displayed + // value ahead of the SDK flag layer. + await session.query.applyFlagSettings({ + effortLevel: toSdkEffortLevel(value), + }); + session.configOptions = session.configOptions.map((o) => + o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o, + ); + session.appliedEffortLevel = value !== "default" ? value : undefined; + // "Default" clears the flag layer (toSdkEffortLevel → null), handing + // effort back to the CLI's persisted per-model resolution — so it + // un-pins; any other pick pins effort for the session. + session.effortPinnedLevel = value !== "default" ? value : undefined; } else { session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o, ); - if (configId === EFFORT_CONFIG_ID) { - await session.query.applyFlagSettings({ - effortLevel: toSdkEffortLevel(value), - }); - // "Default" clears the flag layer (toSdkEffortLevel → null), handing - // effort back to the CLI's persisted per-model resolution — so it - // un-pins; any other pick pins effort for the session. - session.effortPinnedByUser = value !== "default"; - } } } @@ -7886,17 +7982,18 @@ export class ClaudeAcpAgent { ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }), } : undefined); + const configuredSettingsObject = + typeof configuredSettings === "string" + ? (JSON.parse( + await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"), + ) as Settings) + : configuredSettings; // Claude Code applies env from settings.json after the subprocess env. Put // an active ACP route in the programmatic settings tier too so user/project // settings cannot silently restore a different ANTHROPIC_BASE_URL. let settings = configuredSettings; if (resolvedProvider) { - const baseSettings = - typeof configuredSettings === "string" - ? (JSON.parse( - await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"), - ) as Exclude) - : configuredSettings; + const baseSettings = configuredSettingsObject; settings = { ...baseSettings, apiKeyHelper: "", @@ -8241,23 +8338,31 @@ export class ClaudeAcpAgent { disabledReason: fastModeDisabledReason, }; - // The Effort picker seed mirrors what the CLI itself resolves from the - // persisted settings — the current model's `modelSettings` entry first - // (the CLI persists /effort per model), then the legacy top-level value. - // Display-only: no applyFlagSettings here. The CLI reads the same - // settings, so pinning the seed at the flag layer was always redundant — - // and it would override the CLI's own per-model restore on every later - // model switch. Only a user's explicit ACP picker choice pins the flag - // (see applyConfigOptionValue / Session.effortPinnedByUser). + // Concrete effort must also be applied to the SDK: its automatic default + // need not be our "medium" recommendation. Preserve explicit SDK options + // and persisted settings; automatic values re-seed on every model switch. + // Legacy clients still leave effort resolution entirely to the CLI. + const useRecommendedValue = clientSupportsRecommendedConfigValue(this.clientCapabilities); const configOptions = buildConfigOptions( modes, models, modelInfos, - settingsEffortForModel(settingsManager.getSettings(), currentModelInfo), + userProvidedOptions?.effort ?? + settingsEffortForModel( + mergeEffortSettings(settingsManager.getSettings(), configuredSettingsObject), + currentModelInfo, + ), agents, currentAgent, fastMode, + { + useRecommendedValue, + }, ); + const initialEffort = configOptions.find((option) => option.id === EFFORT_CONFIG_ID); + if (useRecommendedValue && typeof initialEffort?.currentValue === "string") { + await q.applyFlagSettings({ effortLevel: toSdkEffortLevel(initialEffort.currentValue) }); + } // Seed the context window without extra IPC. The cached authoritative // window from a prior turn wins (`result.modelUsage`, cross-session), // then the text heuristic, then the default. We deliberately do NOT issue @@ -8293,6 +8398,7 @@ export class ClaudeAcpAgent { sessionFingerprint: computeSessionFingerprint(params), creationParams: params, settingsManager, + effortSettingsOverride: configuredSettingsObject, titles: new SessionTitles(this, sessionId), accumulatedUsage: { inputTokens: 0, @@ -8308,6 +8414,18 @@ export class ClaudeAcpAgent { autoModeFallbackWarningShown: false, autoModeFallbackWarningPending, configOptions, + effortPinnedLevel: + userProvidedOptions?.effort !== undefined && + initialEffort?.currentValue === userProvidedOptions.effort + ? userProvidedOptions.effort + : undefined, + appliedEffortLevel: + useRecommendedValue && typeof initialEffort?.currentValue === "string" + ? initialEffort.currentValue + : userProvidedOptions?.effort !== undefined && + initialEffort?.currentValue === userProvidedOptions.effort + ? userProvidedOptions.effort + : undefined, agents, currentAgent, fastModeEnabled, @@ -8758,45 +8876,6 @@ function isValidBaseUrl(baseUrl: string | undefined): baseUrl is string { return parsed.protocol === "http:" || parsed.protocol === "https:"; } -// Translate a UI effort value into the flag-layer payload. The SDK -// shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport, -// and only clears a key when an explicit `null` is sent — see -// `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the -// `"default"` sentinel and `undefined` (effort option absent for the model) to -// `null` ensures any previously-applied flag is actually cleared. Typed as -// `EffortLevel` (not `Settings["effortLevel"]`): the picker offers whatever -// `supportedEffortLevels` reports, which includes the session-scoped `"max"` -// that the persisted Settings shape deliberately excludes. -function toSdkEffortLevel(value: string | undefined): EffortLevel | null { - return value === undefined || value === "default" ? null : (value as EffortLevel); -} - -/** The effort level the CLI itself resolves for a model from the persisted - * settings: the per-model entry (`modelSettings`, keyed by canonical model - * name — the CLI persists /effort per model since 2.1.243) wins over the - * legacy top-level `effortLevel`. Alias picker rows are looked up by their - * resolved model id first, then the row value, then the raw config value - * for models not in the picker. Exact keys only — the CLI's canonical-form - * fallback matching isn't replicated, so a miss simply falls back to the - * top-level value (best-effort display; `buildConfigOptions` still - * validates the result against the model's supported levels). */ -export function settingsEffortForModel( - settings: Settings, - modelInfo: ModelInfo | undefined, - modelId?: string, -): string | undefined { - const modelSettings = settings.modelSettings; - if (modelSettings) { - for (const key of [modelInfo?.resolvedModel, modelInfo?.value, modelId]) { - const perModel = key !== undefined ? modelSettings[key]?.effortLevel : undefined; - if (typeof perModel === "string") { - return perModel; - } - } - } - return settings.effortLevel; -} - // `supportedAgents()` always returns Claude Code's built-in subagents — the // ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user // has configured none of their own. Those aren't meaningful *main-thread* @@ -8834,7 +8913,6 @@ export async function discoverCustomAgents(q: Query): Promise { * handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the * same identifiers and can't drift apart. */ export { MODE_CONFIG_ID }; -export const MODEL_CONFIG_ID = "model"; export const AGENT_CONFIG_ID = "agent"; export const FAST_MODE_CONFIG_ID = "fast"; @@ -8892,6 +8970,15 @@ export function clientSupportsBooleanConfigOptions( return clientCapabilities?.session?.configOptions?.boolean != null; } +/** Whether the Client advertised the AIR `recommendedValue` capability for + * select-style model and effort options. Missing or malformed AIR metadata + * retains the legacy `default` entries. */ +export function clientSupportsRecommendedConfigValue( + clientCapabilities?: ClientCapabilities | null, +): boolean { + return clientSupportsAirCapability(clientCapabilities, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY); +} + /** Build the Fast mode config option. When the Client supports boolean config * options we expose a native `type: "boolean"` toggle; otherwise we degrade to * a two-value `select` ("on"/"off") so older Clients still get a usable @@ -8961,6 +9048,12 @@ export type FastModeOptionState = { disabledReason?: FastModeDisabledReason; }; +export type ConfigOptionPresentation = { + /** Replace ambiguous `default` rows with concrete values and advertise the + * SDK/adapter recommendation as `_meta.jetbrains.air.recommendedValue`. */ + useRecommendedValue: boolean; +}; + export function buildConfigOptions( modes: SessionModeState, models: SessionModelState, @@ -8969,68 +9062,19 @@ export function buildConfigOptions( agents: AgentInfo[] = [], currentAgent: string = DEFAULT_AGENT_ID, fastMode?: FastModeOptionState, + presentation?: ConfigOptionPresentation, ): SessionConfigOption[] { const options: SessionConfigOption[] = [ SessionModeManager.configOption(modes), - { - id: MODEL_CONFIG_ID, - name: "Model", - description: "AI model to use", - category: "model", - type: "select", - currentValue: models.currentModelId, - options: models.availableModels.map((m) => { - if (m.modelId === "default") { - const defaultInfo = modelInfos.find((mi) => mi.value === "default"); - const resolvedModel = defaultInfo?.resolvedModel; - if (resolvedModel) { - const namedMatch = modelInfos.find( - (mi) => mi.value !== "default" && mi.resolvedModel === resolvedModel, - ); - return { - value: m.modelId, - name: m.name, - description: namedMatch?.displayName ?? resolvedModel, - }; - } - } - return { value: m.modelId, name: m.name, description: m.description ?? undefined }; - }), - }, + buildModelConfigOption(models, modelInfos, presentation?.useRecommendedValue === true), ]; - - // Add effort level option based on the currently selected model - const currentModelInfo = modelInfos.find((m) => m.value === models.currentModelId); - const supportedLevels = currentModelInfo?.supportsEffort - ? (currentModelInfo.supportedEffortLevels ?? []) - : []; - - if (supportedLevels.length > 0) { - const effortOptions = [ - { value: "default", name: "Default" }, - ...supportedLevels.map((level) => ({ - value: level, - name: level - .split(/[_-]/) - .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) - .join(" "), - })), - ]; - - const includes = (l: string) => l === "default" || (supportedLevels as string[]).includes(l); - const validEffort = - currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default"; - - options.push({ - id: EFFORT_CONFIG_ID, - name: "Effort", - description: "Available effort levels for this model", - category: "thought_level", - type: "select", - currentValue: validEffort, - options: effortOptions, - }); - } + const effort = buildEffortConfigOption( + modelInfos, + models.currentModelId, + currentEffortLevel, + presentation?.useRecommendedValue === true, + ); + if (effort) options.push(effort); // Surface the Fast mode toggle only when the current model supports it. The // option renders as a native boolean toggle for Clients that opted in, and a @@ -9070,422 +9114,6 @@ export function buildConfigOptions( return options; } -// Claude Code CLI persists display strings like "opus[1m]" in settings, -// but the SDK model list uses IDs like "claude-opus-4-6-1m". -const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i; - -// The id-suffix spelling of a context hint ("-1m" in "claude-opus-4-6-1m"); -// shared by the strip and canonicalize helpers below so the two can't drift. -const CONTEXT_HINT_SUFFIX_PATTERN = /-(\d+m)$/i; - -/** Remove context-window hints — the display form "[1m]" and the SDK id - * suffix form "-1m" — from a model string. Those digits describe context - * size, not model identity or generation version. */ -function stripContextHints(s: string): string { - return s.replace(/\[\d+m\]/gi, "").replace(CONTEXT_HINT_SUFFIX_PATTERN, ""); -} - -/** Canonicalize a model id for exact comparison: trimmed, lowercased, with - * the id-suffix hint spelling unified to the bracket form ("-1m" → "[1m]"). - * The hint itself is kept — bare and 1M ids must stay distinct. */ -function canonicalizeModelId(s: string): string { - return s.trim().toLowerCase().replace(CONTEXT_HINT_SUFFIX_PATTERN, "[$1]"); -} - -/** The context hint a model string carries ("1m" for either spelling), or - * null for a bare id. */ -function contextHintOf(s: string): string | null { - return canonicalizeModelId(s).match(MODEL_CONTEXT_HINT_PATTERN)?.[1] ?? null; -} - -// Captures a model family version: `4-6`/`4.7` for dated generations, or a -// bare `5` for single-number ones like "Sonnet 5". Used to keep a pinned -// `claude-opus-4-6` from matching the `opus` alias once it points at 4.7. -const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)(?:[-.](\d+))?\b/; - -function extractModelFamilyVersion(s: string): string | null { - const match = stripContextHints(s).match(MODEL_FAMILY_VERSION_PATTERN); - if (!match) return null; - return match[2] ? `${match[1]}.${match[2]}` : match[1]; -} - -function modelVersionsCompatible(preference: string, candidate: ModelInfo): boolean { - const preferred = extractModelFamilyVersion(preference); - if (!preferred) return true; - const candidateVersion = - extractModelFamilyVersion(candidate.value) ?? - extractModelFamilyVersion(candidate.displayName) ?? - extractModelFamilyVersion(candidate.description); - if (!candidateVersion) return true; - return preferred === candidateVersion; -} - -function tokenizeModelPreference(model: string): { tokens: string[]; contextHint?: string } { - const lower = model.trim().toLowerCase(); - const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase(); - - const normalized = lower.replace(MODEL_CONTEXT_HINT_PATTERN, " $1 "); - const rawTokens = normalized.split(/[^a-z0-9]+/).filter(Boolean); - const tokens = rawTokens - .map((token) => { - if (token === "opusplan") return "opus"; - if (token === "best" || token === "default") return ""; - return token; - }) - .filter((token) => token && token !== "claude") - .filter((token) => /[a-z]/.test(token) || token.endsWith("m")); - - return { tokens, contextHint }; -} - -function scoreModelMatch(model: ModelInfo, tokens: string[], contextHint?: string): number { - const haystack = `${model.value} ${model.displayName}`.toLowerCase(); - let score = 0; - let nonHintMatched = false; - for (const token of tokens) { - if (haystack.includes(token)) { - if (token !== contextHint) nonHintMatched = true; - score += token === contextHint ? 3 : 1; - } - } - if (contextHint && !nonHintMatched) return 0; - return score; -} - -export function resolveModelPreference(models: ModelInfo[], preference: string): ModelInfo | null { - const trimmed = preference.trim(); - if (!trimmed) return null; - - const lower = trimmed.toLowerCase(); - - // Exact match on value or display name. Values compare on the canonical - // hint spelling so "opus-1m" hits an "opus[1m]" row (and vice versa). - const canonicalPreference = canonicalizeModelId(trimmed); - const directMatch = models.find( - (model) => - model.value === trimmed || - canonicalizeModelId(model.value) === canonicalPreference || - model.displayName.toLowerCase() === lower, - ); - if (directMatch) return directMatch; - - // Exact match on the alias's canonical resolved id (e.g. a pinned - // "claude-sonnet-5" against the "sonnet" row's `resolvedModel`). SDK- - // reported and unambiguous, so it's tried before the fuzzier tiers below. - // Compared on the canonical hint spelling so a "-1m"-suffix pin matches a - // "[1m]"-spelled resolvedModel instead of falling into the substring tier - // (which would land on the bare 200k sibling). "default" is skipped first - // since it shares a resolvedModel with whichever alias the CLI currently - // recommends — a specific pin should land on that named alias, not - // "default". - const matchesResolved = (model: ModelInfo) => - model.resolvedModel != null && canonicalizeModelId(model.resolvedModel) === canonicalPreference; - const resolvedMatch = - models.find((model) => model.value !== "default" && matchesResolved(model)) ?? - models.find(matchesResolved); - if (resolvedMatch) return resolvedMatch; - - // Substring match. Skips candidates whose context hint disagrees with the - // preference's — a bare row must not absorb a 1M-hinted preference (nor - // vice versa); such pairs fall through to the tokenized tier, which - // weighs hints in its scoring and still finds the best same-family row. - const preferenceHint = contextHintOf(trimmed); - const includesMatch = models.find((model) => { - if (!modelVersionsCompatible(trimmed, model)) return false; - if (contextHintOf(model.value) !== preferenceHint) return false; - const value = model.value.toLowerCase(); - const display = model.displayName.toLowerCase(); - return value.includes(lower) || display.includes(lower) || lower.includes(value); - }); - if (includesMatch) return includesMatch; - - // Tokenized matching for aliases like "opus[1m]" - const { tokens, contextHint } = tokenizeModelPreference(trimmed); - if (tokens.length === 0) return null; - - let bestMatch: ModelInfo | null = null; - let bestScore = 0; - for (const model of models) { - if (!modelVersionsCompatible(trimmed, model)) continue; - const score = scoreModelMatch(model, tokens, contextHint); - if (0 < score && (!bestMatch || bestScore < score)) { - bestMatch = model; - bestScore = score; - } - } - - return bestMatch; -} - -/** Map the live model reported by a resumed session onto the picker's model - * list. The CLI restores a resumed session's model from the transcript's - * last assistant message, which records the concrete API id (e.g. - * "claude-opus-4-6") with any "[1m]" context hint dropped. Tiers, in order: - * 1. Exact match with the Default entry's resolution — when a named alias - * shares Default's resolvedModel verbatim, the live id can't tell the - * two apart, and a never-customized session should stay on Default. - * 2. Exact resolvedModel match on a named row. Checked before the - * hint-stripped Default comparison so a live "claude-sonnet-5[1m]" lands - * on the "sonnet[1m]" row rather than a Default that resolves to the - * bare "claude-sonnet-5" — the two rows differ in context window, which - * drives `contextWindowSize` and capability gating downstream. - * 3. Hint-stripped match with Default's resolution — a session that never - * left the default resumes as the bare transcript id, and shouldn't show - * a concrete picker entry. - * 4. `resolveModelPreference` over the picker entries. - * 5. A model with no picker counterpart (e.g. excluded by an - * `availableModels` allowlist) is tracked verbatim, mirroring - * `syncModelAfterExternalSwitch`: the picker shows no selection, but the - * model-dependent bookkeeping stays truthful to what the SDK is running. */ -export function matchResumedModel(models: ModelInfo[], liveModel: string): ModelInfo { - const live = canonicalizeModelId(liveModel); - const defaultEntry = models.find((m) => m.value === "default"); - const defaultResolved = defaultEntry?.resolvedModel - ? canonicalizeModelId(defaultEntry.resolvedModel) - : undefined; - - if (defaultEntry && defaultResolved === live) { - return defaultEntry; - } - - // No default-row exclusion needed: a default row matching `live` exactly - // already returned at the tier above. - const exactMatch = models.find( - (m) => m.resolvedModel && canonicalizeModelId(m.resolvedModel) === live, - ); - if (exactMatch) return exactMatch; - - if ( - defaultEntry && - defaultResolved && - stripContextHints(defaultResolved) === stripContextHints(live) - ) { - return defaultEntry; - } - - return ( - resolveModelPreference(models, liveModel) ?? { - value: liveModel, - displayName: liveModel, - description: "", - } - ); -} - -function resolveSettingsModel( - models: ModelInfo[], - settingsModel: unknown, - logger: Logger, -): ModelInfo | null { - if (settingsModel === undefined) { - return null; - } - if (typeof settingsModel !== "string") { - const typeLabel = settingsModel === null ? "null" : typeof settingsModel; - logger.error(`Ignoring model from settings: expected a string, got ${typeLabel}.`); - return null; - } - return resolveModelPreference(models, settingsModel); -} - -/** - * Restrict the SDK's model list to the user's `availableModels` allowlist - * (already merged-and-deduped across settings sources by `SettingsManager`). - * The user's exact entries become the model IDs surfaced via configOptions - * and passed to `setModel`, which prevents Claude Code from silently - * substituting a date-pinned variant (e.g. `haiku` → - * `claude-haiku-4-5-20251001`) that the user may not have access to. - * - * Display info and capability flags are copied from the closest SDK match so - * the UI still renders sensible names and effort levels. - * - * Semantics from https://code.claude.com/docs/en/model-config#restrict-model-selection: - * - `undefined` is handled by the caller (no allowlist applied). - * - The Default option is unaffected by `availableModels` — it always remains - * available, even when the allowlist is `[]`. - */ -export function applyAvailableModelsAllowlist( - sdkModels: ModelInfo[], - allowlist: string[], - settingsModelOverrides?: Record, -): ModelInfo[] { - // Default is always preserved per the docs. Synthesize one if the SDK - // didn't surface it so downstream code (e.g. `getAvailableModels` picking - // `models[0]` as a fallback) still has something to work with. - const defaultModel = sdkModels.find((m) => m.value === "default") ?? { - value: "default", - displayName: "Default", - description: "", - }; - const result: ModelInfo[] = [defaultModel]; - const seen = new Set([defaultModel.value]); - - const sdkModelsWithoutDefault = sdkModels.filter((m) => m.value !== "default"); - - // Bedrock/Vertex deployments enforce short aliases (e.g. "claude-opus-4-6") - // in availableModels but require provider-specific IDs at the API. We still - // resolve `sdkMatch` against the alias (`trimmed`) — that's what the - // matching heuristics above are built for, and override targets (ARNs, - // opaque provider IDs) often won't textually resemble anything in - // `sdkModelsWithoutDefault`. Only the entry's surfaced `value` becomes the - // override target, so it's what `setModel` ends up passing to the API. - for (const entry of allowlist) { - const trimmed = entry.trim(); - if (!trimmed || seen.has(trimmed)) continue; - - const overridden = settingsModelOverrides?.[trimmed]; - const effective = overridden ?? trimmed; - if (seen.has(effective)) continue; - - const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed); - if (sdkMatch) { - result.push({ ...sdkMatch, value: effective }); - } else { - result.push({ value: effective, displayName: trimmed, description: "" }); - } - seen.add(effective); - } - - // The custom model option (ANTHROPIC_CUSTOM_MODEL_OPTION) is exempt from the - // allowlist, the same way Default is. Per the model-config docs it adds an - // entry "without replacing the built-in aliases" and "appears at the bottom of - // the /model picker", so we append it last and skip the allowlist filter; this - // keeps a slim alias allowlist from hiding the custom model row. - // https://code.claude.com/docs/en/model-config#add-a-custom-model-option - const customModelOption = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION?.trim(); - if (customModelOption && !seen.has(customModelOption)) { - const customModel = sdkModels.find((m) => m.value === customModelOption); - if (customModel) { - result.push(customModel); - seen.add(customModel.value); - } - } - - return result; -} - -/** Whether a rejected `setModel` was vetoed by a user-configured - * PreModelSwitch hook. The CLI's rejection message is the stable, - * distinguishable marker ("Model switch blocked by a PreModelSwitch hook: - * "); a format change makes this stop matching and the failure - * falls back to the ordinary fail-loud path, no worse than before. */ -function isPreModelSwitchHookBlock(error: unknown): boolean { - return error instanceof Error && error.message.includes("blocked by a PreModelSwitch hook"); -} - -async function getAvailableModels( - query: Query, - models: ModelInfo[], - sdkModels: ModelInfo[], - settingsManager: SettingsManager, - logger: Logger, - isResumedSession: boolean, - sessionId: string, - resumedModelHint?: string, -): Promise { - const settings = settingsManager.getSettings(); - - let currentModel = models[0]; - let resolvedFromInput: string | undefined; - // Model priority (highest to lowest): - // 1. ANTHROPIC_MODEL environment variable - // 2. settings.model (user configuration) - // 3. the resumed session's live model (resumed sessions only) - // 4. models[0] (default first model) - if (process.env.ANTHROPIC_MODEL) { - const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL); - if (match) { - currentModel = match; - resolvedFromInput = process.env.ANTHROPIC_MODEL; - } - } else if (typeof settings.model === "string") { - const match = resolveSettingsModel(models, settings.model, logger); - if (match) { - currentModel = match; - resolvedFromInput = settings.model; - } - } - - // A resumed session restores the model from its last real assistant - // transcript record. Use that same local record instead of getContextUsage: - // the latter is a control request to the live CLI process and can add tens - // of seconds to session/load before its first turn. No `setModel` here: the - // SDK is already running this model, and pushing a picker alias back (e.g. - // "opus[1m]") could change the live model rather than describe it. - if (resolvedFromInput === undefined && isResumedSession) { - currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : currentModel; - } - - // Skip the setModel round-trip when we can prove the SDK has already landed - // on the same model. Two cases qualify: - // (a) No override applied — currentModel is the SDK's own default (or, on - // resume, the live model read back from the SDK above); nothing to sync. - // (b) The resolver returned the user's input verbatim AND that value exists - // in the SDK's original model list — meaning no fuzzy match or - // allowlist rewrite was involved, and the SDK (which reads the same - // ANTHROPIC_MODEL / settings.json) will have arrived at the same entry. - // This only holds for fresh sessions: a resumed session lands on the - // transcript's model regardless of env/settings, so the override must - // be re-asserted to keep the reported model truthful. - // Anything else (fuzzy match, allowlist-synthesized value, alias) gets a - // setModel call so we don't drift from the user's intended pin. - const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value); - const skipSetModel = - resolvedFromInput === undefined || - (!isResumedSession && currentModel.value === resolvedFromInput && sdkSawSameValue); - if (!skipSetModel) { - const setModelStartedAt = performance.now(); - try { - await query.setModel(currentModel.value); - logger.log( - `[session/models] sessionId=${sessionId} phase=set-model durationMs=${Math.round(performance.now() - setModelStartedAt)} model=${currentModel.value} outcome=success`, - ); - } catch (error) { - logger.log( - `[session/models] sessionId=${sessionId} phase=set-model durationMs=${Math.round(performance.now() - setModelStartedAt)} model=${currentModel.value} outcome=error`, - ); - // On a fresh session the pin is a defining option — fail loudly. A - // resumed session already runs fine on the transcript's model, so - // failing the whole session/load over the re-assert would be worse - // than loading with the pin unapplied (mirrors the setPermissionMode - // containment in createSession). The SDK then stayed on the - // transcript's model, so read that back rather than reporting the - // pin the session isn't running. - // - // One fresh-session failure is advisory, not defining: a - // user-configured PreModelSwitch hook can veto the pin (CLI 2.1.251+; - // 'deny', or 'ask' — which headless sessions refuse), and the SDK - // rejects setModel with "Model switch blocked by a PreModelSwitch - // hook: …". Terminal Claude Code never lets a hook veto its startup - // model (the spawn model isn't a switch), so failing session/new here - // would make the same hook config fatal only over ACP. The session - // stays on the SDK's own default — report that. We can't read the - // live model back on this path: getContextUsage isn't serviced on a - // fresh session until the first prompt turn (issues #886/#880). - if (!isResumedSession) { - if (!isPreModelSwitchHookBlock(error)) throw error; - logger.error( - `Model pin "${currentModel.value}" was vetoed by a PreModelSwitch hook; staying on the default model:`, - error, - ); - currentModel = models[0]; - } else { - logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error); - currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : models[0]; - } - } - } - - return { - availableModels: models.map((model) => ({ - modelId: model.value, - name: model.displayName, - description: model.description, - })), - currentModelId: currentModel.value, - }; -} - function getAvailableSlashCommands( commands: SlashCommand[], // Names the CLI tagged terminal-bound on `system`/init (their UX lives in diff --git a/src/air-extension.ts b/src/air-extension.ts index 2d01479b..347b835f 100644 --- a/src/air-extension.ts +++ b/src/air-extension.ts @@ -1,6 +1,7 @@ export const AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY = "nativeSubagentSessions"; export const AIR_ASYNC_TASKS_CAPABILITY = "asyncTasks"; export const AIR_SESSION_FAILURE_CAPABILITY = "sessionFailure"; +export const AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY = "recommendedValue"; const JETBRAINS_META_KEY = "jetbrains"; const AIR_META_KEY = "air"; diff --git a/src/clear-context-coordinator.ts b/src/clear-context-coordinator.ts index 1dc525f6..658b86e2 100644 --- a/src/clear-context-coordinator.ts +++ b/src/clear-context-coordinator.ts @@ -5,7 +5,7 @@ import type { PermissionMode, SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; -import { DEFAULT_AGENT_ID, DEFAULT_MODEL_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js"; +import { DEFAULT_AGENT_ID, DEFAULT_MODEL_ID } from "./session-config-ids.js"; export type ClearContextReset = { toolUseId: string; @@ -39,6 +39,7 @@ export type ClearContextSession(session: Session): NewSessionRequest { const originalParams = session.creationParams ?? { cwd: session.cwd, mcpServers: [] }; - const currentEffort = session.configOptions.find( - (option) => option.id === EFFORT_CONFIG_ID, - )?.currentValue; const originalMeta = originalParams._meta as ({ claudeCode?: { options?: Options } } & Record) | undefined; const originalOptions = originalMeta?.claudeCode?.options; @@ -92,8 +90,8 @@ function restartParams(session: Session): N ? { model: session.models.currentModelId } : {}), ...(session.currentAgent !== DEFAULT_AGENT_ID ? { agent: session.currentAgent } : {}), - ...(typeof currentEffort === "string" && currentEffort !== "default" - ? { effort: currentEffort as EffortLevel } + ...(session.effortPinnedLevel !== undefined + ? { effort: session.effortPinnedLevel as EffortLevel } : {}), }, }, diff --git a/src/session-effort.ts b/src/session-effort.ts new file mode 100644 index 00000000..926168e9 --- /dev/null +++ b/src/session-effort.ts @@ -0,0 +1,118 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import type { EffortLevel, ModelInfo, Settings } from "@anthropic-ai/claude-agent-sdk"; +import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, withAirMeta } from "./air-extension.js"; +import { EFFORT_CONFIG_ID } from "./session-config-ids.js"; + +export { EFFORT_CONFIG_ID } from "./session-config-ids.js"; + +// The SDK drops `undefined` during JSON transport and only clears a flag-layer +// setting when it receives an explicit `null`. Map both an absent picker and +// the legacy "default" row to null so a previously applied effort is cleared. +export function toSdkEffortLevel(value: string | undefined): EffortLevel | null { + return value === undefined || value === "default" ? null : (value as EffortLevel); +} + +function canonicalizeModelSettingsKey(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/-(\d+m)$/i, "[$1]"); +} + +/** Resolve the effort the CLI will use: per-model settings first, then the + * legacy top-level effort setting. Model settings are keyed by canonical + * model name, so try the resolved SDK model before picker and raw IDs. */ +export function settingsEffortForModel( + settings: Settings, + modelInfo: ModelInfo | undefined, + modelId?: string, +): string | undefined { + const modelSettings = settings.modelSettings; + if (modelSettings) { + for (const key of [modelInfo?.resolvedModel, modelInfo?.value, modelId]) { + if (key === undefined) continue; + const exact = modelSettings[key]?.effortLevel; + if (typeof exact === "string") return exact; + const canonicalKey = canonicalizeModelSettingsKey(key); + const matchingEntry = Object.entries(modelSettings).find( + ([candidate]) => canonicalizeModelSettingsKey(candidate) === canonicalKey, + ); + const perModel = matchingEntry?.[1]?.effortLevel; + if (typeof perModel === "string") return perModel; + } + } + return settings.effortLevel; +} + +/** Programmatic query settings have higher priority than file-backed settings, + * matching the SDK. Keep unrelated per-model entries from lower tiers while + * replacing entries supplied at the programmatic tier. */ +export function mergeEffortSettings(base: Settings, override: Settings | undefined): Settings { + if (!override) return base; + const overriddenModelKeys = new Set( + Object.keys(override.modelSettings ?? {}).map(canonicalizeModelSettingsKey), + ); + const unshadowedBaseModelSettings = Object.fromEntries( + Object.entries(base.modelSettings ?? {}).filter( + ([key]) => !overriddenModelKeys.has(canonicalizeModelSettingsKey(key)), + ), + ); + return { + ...base, + ...override, + modelSettings: + base.modelSettings || override.modelSettings + ? { ...unshadowedBaseModelSettings, ...override.modelSettings } + : undefined, + }; +} + +export function buildEffortConfigOption( + modelInfos: ModelInfo[], + currentModelId: string, + currentEffortLevel: string | undefined, + useRecommendedValue: boolean, +): SessionConfigOption | undefined { + const currentModelInfo = modelInfos.find((model) => model.value === currentModelId); + const supportedLevels = currentModelInfo?.supportsEffort + ? (currentModelInfo.supportedEffortLevels ?? []) + : []; + if (supportedLevels.length === 0) return undefined; + + const recommendedEffort = (supportedLevels as string[]).includes("medium") + ? "medium" + : supportedLevels[0]; + const options = [ + ...(useRecommendedValue ? [] : [{ value: "default", name: "Default" }]), + ...supportedLevels.map((level) => ({ + value: level, + name: level + .split(/[_-]/) + .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) + .join(" "), + })), + ]; + const includes = (level: string) => + (!useRecommendedValue && level === "default") || (supportedLevels as string[]).includes(level); + const currentValue = + currentEffortLevel && includes(currentEffortLevel) + ? currentEffortLevel + : useRecommendedValue + ? recommendedEffort + : "default"; + + return { + id: EFFORT_CONFIG_ID, + name: "Effort", + description: "Available effort levels for this model", + category: "thought_level", + type: "select", + currentValue, + options, + ...(useRecommendedValue + ? { + _meta: withAirMeta(undefined, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, recommendedEffort), + } + : {}), + }; +} diff --git a/src/session-model.ts b/src/session-model.ts new file mode 100644 index 00000000..64b32842 --- /dev/null +++ b/src/session-model.ts @@ -0,0 +1,466 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import type { ModelInfo, Query, Settings } from "@anthropic-ai/claude-agent-sdk"; +import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, withAirMeta } from "./air-extension.js"; + +export const MODEL_CONFIG_ID = "model"; + +export type SessionModelState = { + availableModels: Array<{ modelId: string; name: string; description?: string }>; + currentModelId: string; +}; + +type ModelLogger = { + log: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +}; +type ModelSettingsSource = { getSettings(): Settings }; + +const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i; +const CONTEXT_HINT_SUFFIX_PATTERN = /-(\d+m)$/i; +const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)(?:[-.](\d+))?\b/; +const VERSIONABLE_MODEL_DISPLAY_PATTERN = + /^(?:Claude\s+)?([a-z][a-z0-9-]*)(?:\s+\d+(?:\.\d+)?)?(?:\s+\(\d+[mk]\s+context\))?$/i; +const DISPLAY_CONTEXT_SUFFIX_PATTERN = /\s+\((\d+[mk])\s+context\)$/i; + +function stripContextHints(value: string): string { + return value.replace(/\[\d+m\]/gi, "").replace(CONTEXT_HINT_SUFFIX_PATTERN, ""); +} + +function canonicalizeModelId(value: string): string { + return value.trim().toLowerCase().replace(CONTEXT_HINT_SUFFIX_PATTERN, "[$1]"); +} + +function contextHintOf(value: string): string | null { + return canonicalizeModelId(value).match(MODEL_CONTEXT_HINT_PATTERN)?.[1] ?? null; +} + +function extractModelFamilyVersion(value: string): string | null { + const match = stripContextHints(value).match(MODEL_FAMILY_VERSION_PATTERN); + if (!match) return null; + return match[2] ? `${match[1]}.${match[2]}` : match[1]; +} + +function modelVersionsCompatible(preference: string, candidate: ModelInfo): boolean { + const preferred = extractModelFamilyVersion(preference); + if (!preferred) return true; + const candidateVersion = + extractModelFamilyVersion(candidate.value) ?? + extractModelFamilyVersion(candidate.displayName) ?? + extractModelFamilyVersion(candidate.description); + return candidateVersion ? preferred === candidateVersion : true; +} + +function displayModelVersion(model: ModelInfo, family: string): string | undefined { + const familyVersion = new RegExp(`\\b${family}[-\\s]+(\\d+)(?:[-.](\\d+))?`, "i"); + // The display name is the value we are deciding whether to rewrite, so it + // cannot validate its own normalization. Require independent SDK metadata. + for (const source of [model.resolvedModel, model.description, model.value]) { + const match = source?.match(familyVersion); + if (match) return match[2] ? `${match[1]}.${match[2]}` : match[1]; + } + return undefined; +} + +/** Add concrete versions when a terse SDK label names the same family as the + * underlying model metadata. Multi-word/custom labels remain untouched. + * Context size remains in the option description; if normalization would + * collide, callers fall back to the original labels. */ +function versionedModelDisplayName(model: ModelInfo): string { + const versionable = model.displayName.match(VERSIONABLE_MODEL_DISPLAY_PATTERN); + if (!versionable) return model.displayName; + const version = displayModelVersion(model, versionable[1]); + if (!version) return model.displayName; + const contextSuffix = model.displayName.match(DISPLAY_CONTEXT_SUFFIX_PATTERN); + const descriptionKeepsContext = + contextSuffix !== null && + new RegExp(`\\b${contextSuffix[1]}\\s+context\\b`, "i").test(model.description); + const withoutContext = model.displayName.replace(DISPLAY_CONTEXT_SUFFIX_PATTERN, ""); + if (/\b\d+(?:\.\d+)?\b/.test(withoutContext)) { + return descriptionKeepsContext ? withoutContext : model.displayName; + } + if (descriptionKeepsContext || contextSuffix === null) return `${withoutContext} ${version}`; + // Keep the suffix in its original spelling and insert the version before it. + return `${withoutContext} ${version}${contextSuffix[0]}`; +} + +function tokenizeModelPreference(model: string): { tokens: string[]; contextHint?: string } { + const lower = model.trim().toLowerCase(); + const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase(); + const tokens = lower + .replace(MODEL_CONTEXT_HINT_PATTERN, " $1 ") + .split(/[^a-z0-9]+/) + .filter(Boolean) + .map((token) => { + if (token === "opusplan") return "opus"; + if (token === "best" || token === "default") return ""; + return token; + }) + .filter((token) => token && token !== "claude") + .filter((token) => /[a-z]/.test(token) || token.endsWith("m")); + return { tokens, contextHint }; +} + +function scoreModelMatch(model: ModelInfo, tokens: string[], contextHint?: string): number { + const haystack = `${model.value} ${model.displayName}`.toLowerCase(); + let score = 0; + let nonHintMatched = false; + for (const token of tokens) { + if (!haystack.includes(token)) continue; + if (token !== contextHint) nonHintMatched = true; + score += token === contextHint ? 3 : 1; + } + return contextHint && !nonHintMatched ? 0 : score; +} + +export function resolveModelPreference(models: ModelInfo[], preference: string): ModelInfo | null { + const trimmed = preference.trim(); + if (!trimmed) return null; + const lower = trimmed.toLowerCase(); + const canonicalPreference = canonicalizeModelId(trimmed); + const directMatch = models.find( + (model) => + model.value === trimmed || + canonicalizeModelId(model.value) === canonicalPreference || + model.displayName.toLowerCase() === lower, + ); + if (directMatch) return directMatch; + + const matchesResolved = (model: ModelInfo) => + model.resolvedModel != null && canonicalizeModelId(model.resolvedModel) === canonicalPreference; + const resolvedMatch = + models.find((model) => model.value !== "default" && matchesResolved(model)) ?? + models.find(matchesResolved); + if (resolvedMatch) return resolvedMatch; + + const preferenceHint = contextHintOf(trimmed); + const includesMatch = models.find((model) => { + if (!modelVersionsCompatible(trimmed, model)) return false; + if (contextHintOf(model.value) !== preferenceHint) return false; + const value = model.value.toLowerCase(); + const display = model.displayName.toLowerCase(); + return value.includes(lower) || display.includes(lower) || lower.includes(value); + }); + if (includesMatch) return includesMatch; + + const { tokens, contextHint } = tokenizeModelPreference(trimmed); + if (tokens.length === 0) return null; + let bestMatch: ModelInfo | null = null; + let bestScore = 0; + for (const model of models) { + if (!modelVersionsCompatible(trimmed, model)) continue; + const score = scoreModelMatch(model, tokens, contextHint); + if (score > 0 && (!bestMatch || score > bestScore)) { + bestMatch = model; + bestScore = score; + } + } + return bestMatch; +} + +export function matchResumedModel(models: ModelInfo[], liveModel: string): ModelInfo { + const live = canonicalizeModelId(liveModel); + const defaultEntry = models.find((model) => model.value === "default"); + const defaultResolved = defaultEntry?.resolvedModel + ? canonicalizeModelId(defaultEntry.resolvedModel) + : undefined; + if (defaultEntry && defaultResolved === live) return defaultEntry; + const exactMatch = models.find( + (model) => model.resolvedModel && canonicalizeModelId(model.resolvedModel) === live, + ); + if (exactMatch) return exactMatch; + if ( + defaultEntry && + defaultResolved && + stripContextHints(defaultResolved) === stripContextHints(live) + ) { + return defaultEntry; + } + return ( + resolveModelPreference(models, liveModel) ?? { + value: liveModel, + displayName: liveModel, + description: "", + } + ); +} + +export function buildModelConfigOption( + models: SessionModelState, + modelInfos: ModelInfo[], + useRecommendedValue: boolean, +): SessionConfigOption { + const defaultInfo = modelInfos.find((model) => model.value === "default"); + const selectableIds = new Set(models.availableModels.map((model) => model.modelId)); + const concreteInfos = modelInfos.filter( + (model) => model.value !== "default" && selectableIds.has(model.value), + ); + // Recommendations describe the running default, so fuzzy family matching + // must never substitute a different generation or context window. + const defaultResolved = defaultInfo?.resolvedModel; + const recommended = defaultResolved + ? (concreteInfos.find( + (model) => + canonicalizeModelId(model.resolvedModel ?? model.value) === + canonicalizeModelId(defaultResolved), + ) ?? null) + : null; + const concreteRecommendation = useRecommendedValue && recommended !== null; + const available = concreteRecommendation + ? models.availableModels.filter((model) => model.modelId !== "default") + : models.availableModels; + + return { + id: MODEL_CONFIG_ID, + name: "Model", + description: "AI model to use", + category: "model", + type: "select", + currentValue: + concreteRecommendation && models.currentModelId === "default" + ? recommended.value + : models.currentModelId, + ...(concreteRecommendation + ? { + _meta: withAirMeta(undefined, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, recommended.value), + } + : {}), + options: available.map((model) => { + if (model.modelId === "default" && defaultInfo?.resolvedModel) { + const named = modelInfos.find( + (info) => info.value !== "default" && info.resolvedModel === defaultInfo.resolvedModel, + ); + return { + value: model.modelId, + name: model.name, + description: named?.displayName ?? defaultInfo.resolvedModel, + }; + } + return { + value: model.modelId, + name: model.name, + description: model.description ?? undefined, + }; + }), + }; +} + +function resolveSettingsModel( + models: ModelInfo[], + settingsModel: unknown, + logger: ModelLogger, +): ModelInfo | null { + if (settingsModel === undefined) { + return null; + } + if (typeof settingsModel !== "string") { + const typeLabel = settingsModel === null ? "null" : typeof settingsModel; + logger.error(`Ignoring model from settings: expected a string, got ${typeLabel}.`); + return null; + } + return resolveModelPreference(models, settingsModel); +} + +/** + * Restrict the SDK's model list to the user's `availableModels` allowlist + * (already merged-and-deduped across settings sources by `SettingsManager`). + * The user's exact entries become the model IDs surfaced via configOptions + * and passed to `setModel`, which prevents Claude Code from silently + * substituting a date-pinned variant (e.g. `haiku` → + * `claude-haiku-4-5-20251001`) that the user may not have access to. + * + * Display info and capability flags are copied from the closest SDK match so + * the UI still renders sensible names and effort levels. + * + * Semantics from https://code.claude.com/docs/en/model-config#restrict-model-selection: + * - `undefined` is handled by the caller (no allowlist applied). + * - The Default option is unaffected by `availableModels` — it always remains + * available, even when the allowlist is `[]`. + */ +export function applyAvailableModelsAllowlist( + sdkModels: ModelInfo[], + allowlist: string[], + settingsModelOverrides?: Record, +): ModelInfo[] { + // Default is always preserved per the docs. Synthesize one if the SDK + // didn't surface it so downstream code (e.g. `getAvailableModels` picking + // `models[0]` as a fallback) still has something to work with. + const defaultModel = sdkModels.find((m) => m.value === "default") ?? { + value: "default", + displayName: "Default", + description: "", + }; + const result: ModelInfo[] = [defaultModel]; + const seen = new Set([defaultModel.value]); + + const sdkModelsWithoutDefault = sdkModels.filter((m) => m.value !== "default"); + + // Bedrock/Vertex deployments enforce short aliases (e.g. "claude-opus-4-6") + // in availableModels but require provider-specific IDs at the API. We still + // resolve `sdkMatch` against the alias (`trimmed`) — that's what the + // matching heuristics above are built for, and override targets (ARNs, + // opaque provider IDs) often won't textually resemble anything in + // `sdkModelsWithoutDefault`. Only the entry's surfaced `value` becomes the + // override target, so it's what `setModel` ends up passing to the API. + for (const entry of allowlist) { + const trimmed = entry.trim(); + if (!trimmed || seen.has(trimmed)) continue; + + const overridden = settingsModelOverrides?.[trimmed]; + const effective = overridden ?? trimmed; + if (seen.has(effective)) continue; + + const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed); + if (sdkMatch) { + result.push({ ...sdkMatch, value: effective }); + } else { + result.push({ value: effective, displayName: trimmed, description: "" }); + } + seen.add(effective); + } + + // The custom model option (ANTHROPIC_CUSTOM_MODEL_OPTION) is exempt from the + // allowlist, the same way Default is. Per the model-config docs it adds an + // entry "without replacing the built-in aliases" and "appears at the bottom of + // the /model picker", so we append it last and skip the allowlist filter; this + // keeps a slim alias allowlist from hiding the custom model row. + // https://code.claude.com/docs/en/model-config#add-a-custom-model-option + const customModelOption = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION?.trim(); + if (customModelOption && !seen.has(customModelOption)) { + const customModel = sdkModels.find((m) => m.value === customModelOption); + if (customModel) { + result.push(customModel); + seen.add(customModel.value); + } + } + + return result; +} + +/** Whether a rejected `setModel` was vetoed by a user-configured + * PreModelSwitch hook. The CLI's rejection message is the stable, + * distinguishable marker ("Model switch blocked by a PreModelSwitch hook: + * "); a format change makes this stop matching and the failure + * falls back to the ordinary fail-loud path, no worse than before. */ +function isPreModelSwitchHookBlock(error: unknown): boolean { + return error instanceof Error && error.message.includes("blocked by a PreModelSwitch hook"); +} + +export async function getAvailableModels( + query: Query, + models: ModelInfo[], + sdkModels: ModelInfo[], + settingsManager: ModelSettingsSource, + logger: ModelLogger, + isResumedSession: boolean, + sessionId: string, + resumedModelHint?: string, +): Promise { + const settings = settingsManager.getSettings(); + + let currentModel = models[0]; + let resolvedFromInput: string | undefined; + // Model priority (highest to lowest): + // 1. ANTHROPIC_MODEL environment variable + // 2. settings.model (user configuration) + // 3. the resumed session's live model (resumed sessions only) + // 4. models[0] (default first model) + if (process.env.ANTHROPIC_MODEL) { + const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL); + if (match) { + currentModel = match; + resolvedFromInput = process.env.ANTHROPIC_MODEL; + } + } else if (typeof settings.model === "string") { + const match = resolveSettingsModel(models, settings.model, logger); + if (match) { + currentModel = match; + resolvedFromInput = settings.model; + } + } + + // A resumed session restores the model from its last real assistant + // transcript record. Use that same local record instead of getContextUsage: + // the latter is a control request to the live CLI process and can add tens + // of seconds to session/load before its first turn. No `setModel` here: the + // SDK is already running this model, and pushing a picker alias back (e.g. + // "opus[1m]") could change the live model rather than describe it. + if (resolvedFromInput === undefined && isResumedSession) { + currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : currentModel; + } + + // Skip the setModel round-trip when we can prove the SDK has already landed + // on the same model. Two cases qualify: + // (a) No override applied — currentModel is the SDK's own default (or, on + // resume, the live model read back from the SDK above); nothing to sync. + // (b) The resolver returned the user's input verbatim AND that value exists + // in the SDK's original model list — meaning no fuzzy match or + // allowlist rewrite was involved, and the SDK (which reads the same + // ANTHROPIC_MODEL / settings.json) will have arrived at the same entry. + // This only holds for fresh sessions: a resumed session lands on the + // transcript's model regardless of env/settings, so the override must + // be re-asserted to keep the reported model truthful. + // Anything else (fuzzy match, allowlist-synthesized value, alias) gets a + // setModel call so we don't drift from the user's intended pin. + const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value); + const skipSetModel = + resolvedFromInput === undefined || + (!isResumedSession && currentModel.value === resolvedFromInput && sdkSawSameValue); + if (!skipSetModel) { + const setModelStartedAt = performance.now(); + try { + await query.setModel(currentModel.value); + logger.log( + `[session/models] sessionId=${sessionId} phase=set-model durationMs=${Math.round(performance.now() - setModelStartedAt)} model=${currentModel.value} outcome=success`, + ); + } catch (error) { + logger.log( + `[session/models] sessionId=${sessionId} phase=set-model durationMs=${Math.round(performance.now() - setModelStartedAt)} model=${currentModel.value} outcome=error`, + ); + // On a fresh session the pin is a defining option — fail loudly. A + // resumed session already runs fine on the transcript's model, so + // failing the whole session/load over the re-assert would be worse + // than loading with the pin unapplied (mirrors the setPermissionMode + // containment in createSession). The SDK then stayed on the + // transcript's model, so read that back rather than reporting the + // pin the session isn't running. + // + // One fresh-session failure is advisory, not defining: a + // user-configured PreModelSwitch hook can veto the pin (CLI 2.1.251+; + // 'deny', or 'ask' — which headless sessions refuse), and the SDK + // rejects setModel with "Model switch blocked by a PreModelSwitch + // hook: …". Terminal Claude Code never lets a hook veto its startup + // model (the spawn model isn't a switch), so failing session/new here + // would make the same hook config fatal only over ACP. The session + // stays on the SDK's own default — report that. We can't read the + // live model back on this path: getContextUsage isn't serviced on a + // fresh session until the first prompt turn (issues #886/#880). + if (!isResumedSession) { + if (!isPreModelSwitchHookBlock(error)) throw error; + logger.error( + `Model pin "${currentModel.value}" was vetoed by a PreModelSwitch hook; staying on the default model:`, + error, + ); + currentModel = models[0]; + } else { + logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error); + currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : models[0]; + } + } + } + + const displayNames = models.map(versionedModelDisplayName); + const displayNameCounts = new Map(); + for (const name of displayNames) { + displayNameCounts.set(name, (displayNameCounts.get(name) ?? 0) + 1); + } + + return { + availableModels: models.map((model, index) => ({ + modelId: model.value, + name: + displayNameCounts.get(displayNames[index]) === 1 ? displayNames[index] : model.displayName, + description: model.description, + })), + currentModelId: currentModel.value, + }; +} diff --git a/src/tests/acp-agent-settings.test.ts b/src/tests/acp-agent-settings.test.ts index d6b8478a..e2806255 100644 --- a/src/tests/acp-agent-settings.test.ts +++ b/src/tests/acp-agent-settings.test.ts @@ -97,6 +97,118 @@ describe("ClaudeAcpAgent settings", () => { expect(response.modes.currentModeId).toBe("default"); }, 15_000); + it.each([ + { recommended: false, setting: undefined, explicit: undefined, expected: "default" }, + { recommended: false, setting: "low", explicit: "high", expected: "high" }, + { recommended: true, setting: undefined, explicit: undefined, expected: "medium" }, + { recommended: true, setting: "high", explicit: undefined, expected: "high" }, + { recommended: true, setting: "high", explicit: "max", expected: "max" }, + ])( + "initial effort reflects SDK state: %j", + async ({ recommended, setting, explicit, expected }) => { + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ effortLevel: setting }), + ); + const applyFlagSettings = vi.fn(); + querySpy.mockReturnValue( + makeMockQuery({ + initializationResult: async () => ({ + models: [ + { + value: "default", + displayName: "Default", + description: "", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "max"], + }, + ], + }), + applyFlagSettings, + }), + ); + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent = new ClaudeAcpAgent(createMockClient()); + if (recommended) { + (agent as any).clientCapabilities = { + _meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } }, + }; + } + const response = await (agent as any).createSession({ + cwd: tempDir, + mcpServers: [], + _meta: { disableBuiltInTools: true, claudeCode: { options: { effort: explicit } } }, + }); + expect( + response.configOptions.find((option: any) => option.id === "effort").currentValue, + ).toBe(expected); + if (recommended) { + expect(applyFlagSettings).toHaveBeenCalledWith({ effortLevel: expected }); + } else { + expect(applyFlagSettings).not.toHaveBeenCalled(); + } + expect(agent.sessions[response.sessionId].effortPinnedLevel).toBe(explicit); + expect(agent.sessions[response.sessionId].appliedEffortLevel).toBe( + recommended ? expected : explicit, + ); + }, + ); + + it("seeds effort from higher-priority programmatic model settings", async () => { + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ + effortLevel: "high", + modelSettings: { "claude-sonnet-5[1m]": { effortLevel: "high" } }, + }), + ); + const applyFlagSettings = vi.fn(); + querySpy.mockReturnValue( + makeMockQuery({ + initializationResult: async () => ({ + models: [ + { + value: "sonnet[1m]", + resolvedModel: "claude-sonnet-5[1m]", + displayName: "Sonnet", + description: "", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + ], + }), + applyFlagSettings, + }), + ); + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent = new ClaudeAcpAgent(createMockClient()); + (agent as any).clientCapabilities = { + _meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } }, + }; + + const response = await (agent as any).createSession({ + cwd: tempDir, + mcpServers: [], + _meta: { + disableBuiltInTools: true, + claudeCode: { + options: { + settings: { + effortLevel: "medium", + modelSettings: { "claude-sonnet-5-1m": { effortLevel: "low" } }, + }, + }, + }, + }, + }); + + expect(response.configOptions.find((option: any) => option.id === "effort").currentValue).toBe( + "low", + ); + expect(applyFlagSettings).toHaveBeenCalledWith({ effortLevel: "low" }); + expect(agent.sessions[response.sessionId].effortSettingsOverride?.effortLevel).toBe("medium"); + }); + it("supports acceptEdits mode defaults", async () => { await fs.promises.writeFile( path.join(tempDir, "settings.json"), diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 0da491db..8bcb115f 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -8385,6 +8385,7 @@ describe("logout", () => { "agentFileChangeReport", "nativeSubagentSessions", "asyncTasks", + "recommendedValue", ], }); }); @@ -8403,6 +8404,7 @@ describe("logout", () => { "agentFileChangeReport", "nativeSubagentSessions", "asyncTasks", + "recommendedValue", ], }); }); diff --git a/src/tests/clear-context-coordinator.test.ts b/src/tests/clear-context-coordinator.test.ts index 84e5fd7a..33ed3cbd 100644 --- a/src/tests/clear-context-coordinator.test.ts +++ b/src/tests/clear-context-coordinator.test.ts @@ -79,6 +79,7 @@ describe("continuePlanInFreshContext", () => { models: { currentModelId: "claude-sonnet" }, currentAgent: "reviewer", fastModeEnabled: true, + effortPinnedLevel: "high", configOptions: [ { id: "effort", @@ -145,6 +146,41 @@ describe("continuePlanInFreshContext", () => { expect(host.ensureConsumer).toHaveBeenCalledWith(freshSession, "public-session"); }); + it("does not turn an automatic displayed effort into a pin during restart", async () => { + const turn: ClearContextTurn = { + settled: false, + promptUuid: "00000000-0000-4000-8000-000000000000", + }; + const oldSession = testSession({ + activeTurn: turn, + configOptions: [ + { + id: "effort", + name: "Effort", + type: "select", + currentValue: "high", + options: [{ value: "high", name: "High" }], + }, + ], + creationParams: { + cwd: "/workspace", + mcpServers: [], + _meta: { claudeCode: { options: { effort: "max" } } }, + }, + }); + const host = testHost(oldSession, testSession()); + + await continuePlanInFreshContext( + "public-session", + oldSession, + { toolUseId: "tool-plan", plan: "Ship it", mode: "default" }, + host, + ); + + const params = vi.mocked(host.restartSession).mock.calls[0]?.[0]; + expect((params?._meta as any)?.claudeCode?.options).not.toHaveProperty("effort"); + }); + it("does not resurrect original preferences after the session returns to defaults", async () => { const turn: ClearContextTurn = { settled: false, diff --git a/src/tests/model-presentation.test.ts b/src/tests/model-presentation.test.ts new file mode 100644 index 00000000..e6647e70 --- /dev/null +++ b/src/tests/model-presentation.test.ts @@ -0,0 +1,212 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { query, type ModelInfo, type Query } from "@anthropic-ai/claude-agent-sdk"; +import { buildModelConfigOption, getAvailableModels } from "../session-model.js"; + +const OPUS: ModelInfo = { + value: "opus[1m]", + resolvedModel: "claude-opus-5[1m]", + displayName: "Opus (1M context)", + description: "Opus 5 with 1M context · Best for everyday, complex tasks", +}; + +describe("versioned model display names", () => { + beforeEach(() => vi.stubEnv("ANTHROPIC_MODEL", undefined)); + afterEach(() => vi.unstubAllEnvs()); + + it("requires reviewing the live model contract whenever the SDK is upgraded", () => { + const require = createRequire(import.meta.url); + const sdkPackage = JSON.parse( + readFileSync( + join(dirname(require.resolve("@anthropic-ai/claude-agent-sdk")), "package.json"), + "utf8", + ), + ); + expect( + sdkPackage.version, + "Run RUN_INTEGRATION_TESTS=true npx vitest run src/tests/model-presentation.test.ts " + + "and review the Opus name normalization before updating this version guard.", + ).toBe("0.3.257"); + }); + + it("adds standard-family versions and preserves model identity and recommendation", async () => { + const models: ModelInfo[] = [ + { ...OPUS, value: "default", displayName: "Default (recommended)" }, + OPUS, + { value: "sonnet[1m]", displayName: "Sonnet 5 (1M context)", description: "" }, + { value: "custom", displayName: "Custom Opus (1M context)", description: "" }, + ]; + const setModel = vi.fn(); + const state = await getAvailableModels( + { setModel } as unknown as Query, + models, + models, + { getSettings: () => ({}) }, + { log: vi.fn(), error: vi.fn() }, + false, + "model-presentation-test", + ); + + expect(state.availableModels).toEqual( + models.map((model) => ({ + modelId: model.value, + name: model === OPUS ? "Opus 5" : model.displayName, + description: model.description, + })), + ); + expect(buildModelConfigOption(state, models, true)).toMatchObject({ + currentValue: "opus[1m]", + _meta: { jetbrains: { air: { recommendedValue: "opus[1m]" } } }, + options: expect.arrayContaining([ + { value: "opus[1m]", name: "Opus 5", description: OPUS.description }, + ]), + }); + expect(OPUS.displayName).toBe("Opus (1M context)"); + expect(setModel).not.toHaveBeenCalled(); + }); + + it("adds major and minor versions without changing custom or already-versioned names", async () => { + const models: ModelInfo[] = [ + { + value: "sonnet", + resolvedModel: "claude-sonnet-5", + displayName: "Sonnet", + description: "Sonnet 5 · Efficient for routine tasks", + }, + { + value: "haiku", + resolvedModel: "claude-haiku-4-5-20251001", + displayName: "Claude Haiku", + description: "Fast", + }, + { + value: "fable", + resolvedModel: "claude-fable-5-1", + displayName: "Fable", + description: "Fable 5.1 · Creative model", + }, + { + value: "opus[1m]", + resolvedModel: "claude-opus-5[1m]", + displayName: "Opus 5 (1M context)", + description: "", + }, + { value: "custom", displayName: "My Sonnet", description: "" }, + { value: "future", displayName: "Haiku (1M context)", description: "" }, + { value: "custom-versioned", displayName: "Fable 5.1 (1M context)", description: "" }, + ]; + const state = await getAvailableModels( + {} as Query, + models, + models, + { getSettings: () => ({}) }, + { log: vi.fn(), error: vi.fn() }, + false, + "model-presentation-test", + ); + + expect(state.availableModels.map((model) => model.name)).toEqual([ + "Sonnet 5", + "Claude Haiku 4.5", + "Fable 5.1", + "Opus 5 (1M context)", + "My Sonnet", + "Haiku (1M context)", + "Fable 5.1 (1M context)", + ]); + }); + + it("only moves the context hint out of the name when the description retains it", async () => { + const models: ModelInfo[] = [ + { + value: "opus-described[1m]", + resolvedModel: "claude-opus-5[1m]", + displayName: "Opus 5 (1M context)", + description: "Opus 5 with 1M context", + }, + { + value: "opus-undescribed[1m]", + resolvedModel: "claude-opus-5[1m]", + displayName: "Opus 5 (1M context)", + description: "", + }, + { + value: "fable-undescribed[1m]", + resolvedModel: "claude-fable-5-1[1m]", + displayName: "Fable (1M context)", + description: "", + }, + ]; + const state = await getAvailableModels( + {} as Query, + models, + models, + { getSettings: () => ({}) }, + { log: vi.fn(), error: vi.fn() }, + false, + "model-presentation-test", + ); + + expect(state.availableModels.map((model) => model.name)).toEqual([ + "Opus 5", + "Opus 5 (1M context)", + "Fable 5.1 (1M context)", + ]); + }); + + it("keeps the suffix if another selectable model is already named Opus", async () => { + const models = [OPUS, { ...OPUS, value: "opus", displayName: "Opus" }]; + const state = await getAvailableModels( + {} as Query, + models, + models, + { getSettings: () => ({}) }, + { log: vi.fn(), error: vi.fn() }, + false, + "model-presentation-test", + ); + expect(state.availableModels.map((model) => model.name)).toEqual(["Opus (1M context)", "Opus"]); + }); +}); + +// Account-dependent model availability requires the same authenticated environment +// as the adapter. No prompt is sent: inspect only the SDK initialization response. +it.skipIf(!process.env.RUN_INTEGRATION_TESTS)( + "the real SDK exposes one named Opus with the expected context suffix", + async () => { + let finishInput!: () => void; + const inputGate = new Promise((resolve) => { + finishInput = resolve; + }); + // Keep stdin open for initialization without ever submitting a prompt. + // eslint-disable-next-line require-yield + async function* input() { + await inputGate; + } + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), 25000); + const q = query({ prompt: input(), options: { persistSession: false, abortController } }); + try { + const { models } = await q.initializationResult(); + const opusModels = models.filter( + (model) => + model.value !== "default" && + /\bopus\b/i.test(`${model.value} ${model.resolvedModel} ${model.displayName}`), + ); + expect(opusModels).toHaveLength(1); + expect(opusModels[0]).toMatchObject({ + value: "opus[1m]", + displayName: "Opus (1M context)", + resolvedModel: expect.stringMatching(/^claude-opus-.*\[1m\]$/), + description: expect.stringContaining("1M context"), + }); + } finally { + clearTimeout(timeout); + finishInput(); + q.close(); + } + }, + 30000, +); diff --git a/src/tests/model-resolution.test.ts b/src/tests/model-resolution.test.ts index 5d7bac25..f4e1fd72 100644 --- a/src/tests/model-resolution.test.ts +++ b/src/tests/model-resolution.test.ts @@ -6,8 +6,8 @@ import { resolveModelPreference, applyAvailableModelsAllowlist, matchResumedModel, - settingsEffortForModel, -} from "../acp-agent.js"; +} from "../session-model.js"; +import { settingsEffortForModel } from "../session-effort.js"; // Mirrors a real `supportedModels()` response: alias rows carry // `resolvedModel`, and "Sonnet 5" has no `major.minor` dot unlike older @@ -296,6 +296,20 @@ describe("settingsEffortForModel", () => { expect(settingsEffortForModel(byRawId, undefined, "claude-x")).toBe("xhigh"); }); + it("matches equivalent -1m and [1m] modelSettings keys", () => { + const settings = { + effortLevel: "high" as const, + modelSettings: { "claude-sonnet-5-1m": { effortLevel: "low" as const } }, + }; + expect( + settingsEffortForModel(settings, { + ...SONNET, + value: "sonnet[1m]", + resolvedModel: "claude-sonnet-5[1m]", + }), + ).toBe("low"); + }); + it("falls back to the top-level effortLevel when no per-model entry matches", () => { expect(settingsEffortForModel({ effortLevel: "high" }, SONNET)).toBe("high"); expect( diff --git a/src/tests/recommended-config-values.test.ts b/src/tests/recommended-config-values.test.ts new file mode 100644 index 00000000..230247ed --- /dev/null +++ b/src/tests/recommended-config-values.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from "vitest"; +import type { ClientCapabilities } from "@agentclientprotocol/sdk"; +import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; +import { buildConfigOptions, clientSupportsRecommendedConfigValue } from "../acp-agent.js"; +import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY } from "../air-extension.js"; + +const MODES = { + currentModeId: "default", + availableModes: [ + { id: "default", name: "Manual", description: "Always ask before making changes" }, + { id: "plan", name: "Plan", description: "Create a plan before making changes" }, + ], +}; + +const MODELS = { + currentModelId: "default", + availableModels: [ + { modelId: "default", name: "Default" }, + { modelId: "opus", name: "Claude Opus" }, + { modelId: "sonnet", name: "Claude Sonnet" }, + { modelId: "haiku", name: "Claude Haiku" }, + ], +}; + +const MODEL_INFOS: ModelInfo[] = [ + { + value: "default", + displayName: "Default", + description: "", + resolvedModel: "claude-sonnet-4-6", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + { + value: "opus", + displayName: "Claude Opus", + description: "Most capable", + resolvedModel: "claude-opus-4-6", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + { + value: "sonnet", + displayName: "Claude Sonnet", + description: "Balanced", + resolvedModel: "claude-sonnet-4-6", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + { + value: "haiku", + displayName: "Claude Haiku", + description: "Fastest", + resolvedModel: "claude-haiku-4-5", + }, +]; + +const RECOMMENDED_PRESENTATION = { useRecommendedValue: true }; + +function recommendedMeta(value: string) { + return { jetbrains: { air: { version: 1, recommendedValue: value } } }; +} + +function selectOption(options: ReturnType, id: string) { + const option = options.find((candidate) => candidate.id === id); + expect(option).toMatchObject({ id, type: "select" }); + return option as Extract<(typeof options)[number], { type: "select" }>; +} + +describe("recommended config value capability", () => { + it("is enabled through the common AIR capability list", () => { + const capabilities: ClientCapabilities = { + _meta: { + jetbrains: { + air: { + version: 1, + capabilities: [AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY], + }, + }, + }, + }; + + expect(clientSupportsRecommendedConfigValue(capabilities)).toBe(true); + }); + + it("keeps legacy behavior for omitted and malformed AIR metadata", () => { + expect(clientSupportsRecommendedConfigValue(undefined)).toBe(false); + expect(clientSupportsRecommendedConfigValue(null)).toBe(false); + expect(clientSupportsRecommendedConfigValue({})).toBe(false); + expect( + clientSupportsRecommendedConfigValue({ + _meta: { + jetbrains: { + air: { version: 1, capabilities: ["someOtherCapability"] }, + }, + }, + }), + ).toBe(false); + expect( + clientSupportsRecommendedConfigValue({ + _meta: { + jetbrains: { + air: { + version: "1", + capabilities: [AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY], + }, + }, + }, + }), + ).toBe(false); + }); +}); + +describe("buildConfigOptions recommended values presentation", () => { + it("retains default model and effort entries without the capability", () => { + const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined); + + const model = selectOption(options, "model"); + expect(model.currentValue).toBe("default"); + expect(model.options).toEqual( + expect.arrayContaining([expect.objectContaining({ value: "default" })]), + ); + expect(model._meta).toBeUndefined(); + + const effort = selectOption(options, "effort"); + expect(effort.currentValue).toBe("default"); + expect(effort.options).toEqual( + expect.arrayContaining([expect.objectContaining({ value: "default" })]), + ); + expect(effort._meta).toBeUndefined(); + }); + + it("omits default entries and advertises concrete recommendations", () => { + const options = buildConfigOptions( + MODES, + MODELS, + MODEL_INFOS, + undefined, + [], + "default", + undefined, + RECOMMENDED_PRESENTATION, + ); + + const mode = selectOption(options, "mode"); + expect(mode.currentValue).toBe("default"); + expect(mode.options).toEqual( + expect.arrayContaining([expect.objectContaining({ value: "default" })]), + ); + expect(mode._meta).toBeUndefined(); + + const model = selectOption(options, "model"); + expect(model.currentValue).toBe("sonnet"); + expect(model.options).toHaveLength(3); + expect(model.options.map((option) => ("value" in option ? option.value : undefined))).toEqual([ + "opus", + "sonnet", + "haiku", + ]); + expect(model.options).not.toEqual( + expect.arrayContaining([expect.objectContaining({ value: "default" })]), + ); + expect(model._meta).toEqual(recommendedMeta("sonnet")); + + const effort = selectOption(options, "effort"); + expect(effort.currentValue).toBe("medium"); + expect(effort.options).not.toEqual( + expect.arrayContaining([expect.objectContaining({ value: "default" })]), + ); + expect(effort._meta).toEqual(recommendedMeta("medium")); + }); + + it("preserves explicit selections while keeping independent recommendations", () => { + const options = buildConfigOptions( + MODES, + { ...MODELS, currentModelId: "opus" }, + MODEL_INFOS, + "high", + [], + "default", + undefined, + RECOMMENDED_PRESENTATION, + ); + + expect(selectOption(options, "model")).toMatchObject({ + currentValue: "opus", + _meta: recommendedMeta("sonnet"), + }); + expect(selectOption(options, "effort")).toMatchObject({ + currentValue: "high", + _meta: recommendedMeta("medium"), + }); + }); + + it("keeps the legacy model sentinel when the SDK recommendation is not selectable", () => { + const options = buildConfigOptions( + MODES, + MODELS, + MODEL_INFOS.filter((model) => model.value === "default"), + undefined, + [], + "default", + undefined, + RECOMMENDED_PRESENTATION, + ); + + const model = selectOption(options, "model"); + expect(model.currentValue).toBe("default"); + expect(model.options).toEqual( + expect.arrayContaining([expect.objectContaining({ value: "default" })]), + ); + expect(model._meta).toBeUndefined(); + }); + + it.each(["claude-sonnet-4-6[1m]", "claude-sonnet-5"])( + "does not substitute a similar model for the unavailable default %s", + (resolvedModel) => { + const infos = MODEL_INFOS.map((model) => + model.value === "default" ? { ...model, resolvedModel } : model, + ); + const options = buildConfigOptions( + MODES, + MODELS, + infos, + undefined, + [], + "default", + undefined, + RECOMMENDED_PRESENTATION, + ); + expect(selectOption(options, "model")).toMatchObject({ currentValue: "default" }); + expect(selectOption(options, "model")._meta).toBeUndefined(); + }, + ); + + it("matches equivalent SDK context suffix spellings exactly", () => { + const infos = MODEL_INFOS.map((model) => ({ + ...model, + resolvedModel: + model.value === "default" + ? "claude-sonnet-4-6-1m" + : model.value === "sonnet" + ? "claude-sonnet-4-6[1m]" + : model.resolvedModel, + })); + const options = buildConfigOptions( + MODES, + MODELS, + infos, + undefined, + [], + "default", + undefined, + RECOMMENDED_PRESENTATION, + ); + expect(selectOption(options, "model")).toMatchObject({ + currentValue: "sonnet", + _meta: recommendedMeta("sonnet"), + }); + }); + + it("uses an available effort when medium is not supported", () => { + const infos = MODEL_INFOS.map((model) => ({ + ...model, + supportedEffortLevels: ["high", "max"] as ModelInfo["supportedEffortLevels"], + })); + const options = buildConfigOptions( + MODES, + MODELS, + infos, + undefined, + [], + "default", + undefined, + RECOMMENDED_PRESENTATION, + ); + expect(selectOption(options, "effort")).toMatchObject({ + currentValue: "high", + _meta: recommendedMeta("high"), + }); + }); +}); diff --git a/src/tests/session-config-options.test.ts b/src/tests/session-config-options.test.ts index 8d3c753c..0199d0a1 100644 --- a/src/tests/session-config-options.test.ts +++ b/src/tests/session-config-options.test.ts @@ -430,7 +430,7 @@ describe("session config options", () => { const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; const effortOpt = session.configOptions.find((o: any) => o.id === "effort"); if (effortOpt) effortOpt.currentValue = "max"; - session.effortPinnedByUser = true; + session.effortPinnedLevel = "max"; session.modelInfos = [ { @@ -467,7 +467,7 @@ describe("session config options", () => { const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; const effortOpt = session.configOptions.find((o: any) => o.id === "effort"); if (effortOpt) effortOpt.currentValue = "low"; - session.effortPinnedByUser = true; + session.effortPinnedLevel = "low"; const response = await agent.setSessionConfigOption({ sessionId: SESSION_ID, @@ -565,6 +565,23 @@ describe("session config options", () => { expect(effortOption?.currentValue).toBe("medium"); }); + it("keeps effort state unchanged when the SDK rejects a direct selection", async () => { + applyFlagSettingsSpy.mockRejectedValueOnce(new Error("effort update failed")); + + await expect( + agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "effort", + value: "low", + }), + ).rejects.toThrow("effort update failed"); + + const session = agent.sessions[SESSION_ID]; + expect(session.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("default"); + expect(session.effortPinnedLevel).toBeUndefined(); + expect(session.appliedEffortLevel).toBeUndefined(); + }); + it("throws for invalid effort value", async () => { await expect( agent.setSessionConfigOption({ @@ -607,6 +624,192 @@ describe("session config options", () => { populateSession(); }); + it("applies concrete defaults and re-seeds automatic effort from each model's settings", async () => { + (agent as any).clientCapabilities = { + _meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } }, + }; + const session = agent.sessions[SESSION_ID]; + session.settingsManager.getSettings = () => ({ + modelSettings: { "claude-opus-4-5": { effortLevel: "high" } }, + }); + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-6", + }); + expect(response.configOptions.find((o) => o.id === "effort")).toMatchObject({ + currentValue: "medium", + _meta: { jetbrains: { air: { recommendedValue: "medium" } } }, + }); + expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: "medium" }); + expect(session.effortPinnedLevel).toBeUndefined(); + expect(session.appliedEffortLevel).toBe("medium"); + + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-opus-4-5", + }); + expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: "high" }); + expect(session.effortPinnedLevel).toBeUndefined(); + expect(session.appliedEffortLevel).toBe("high"); + + session.modelInfos[1].supportsEffort = false; + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-6", + }); + expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: null }); + }); + + it("re-seeds switches from retained programmatic settings before file settings", async () => { + (agent as any).clientCapabilities = { + _meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } }, + }; + const session = agent.sessions[SESSION_ID]; + session.settingsManager.getSettings = () => ({ effortLevel: "high" }); + session.effortSettingsOverride = { + effortLevel: "medium", + modelSettings: { "claude-sonnet-4-6": { effortLevel: "low" } }, + }; + + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-6", + }); + + expect(response.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("low"); + expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: "low" }); + }); + + it("clears an unsupported user pin before choosing the new model's concrete effort", async () => { + (agent as any).clientCapabilities = { + _meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } }, + }; + const session = agent.sessions[SESSION_ID]; + session.modelInfos[0].supportedEffortLevels = ["low", "medium", "high", "max"]; + session.settingsManager.getSettings = () => ({ effortLevel: "low" }); + session.configOptions.find((o) => o.id === "effort")!.currentValue = "max"; + session.effortPinnedLevel = "max"; + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-6", + }); + expect(response.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("low"); + expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: "low" }); + expect(session.effortPinnedLevel).toBeUndefined(); + expect(session.appliedEffortLevel).toBe("low"); + }); + + it("clears a legacy pin without promoting persisted effort to a flag override", async () => { + const session = agent.sessions[SESSION_ID]; + session.modelInfos[0].supportedEffortLevels = ["low", "medium", "high", "max"]; + session.settingsManager.getSettings = () => ({ effortLevel: "low" }); + session.configOptions.find((o) => o.id === "effort")!.currentValue = "max"; + session.effortPinnedLevel = "max"; + + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-6", + }); + + expect(response.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("low"); + expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: null }); + expect(session.effortPinnedLevel).toBeUndefined(); + expect(session.appliedEffortLevel).toBeUndefined(); + }); + + it("retains the original pin value when a legacy clamp fails", async () => { + const session = agent.sessions[SESSION_ID]; + session.modelInfos[0].supportedEffortLevels = ["low", "medium", "high", "max"]; + session.settingsManager.getSettings = () => ({ effortLevel: "low" }); + session.configOptions.find((o) => o.id === "effort")!.currentValue = "max"; + session.effortPinnedLevel = "max"; + applyFlagSettingsSpy.mockRejectedValueOnce(new Error("effort clear failed")); + + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-6", + }); + expect(session.configOptions.find((o) => o.id === "effort")).toBeUndefined(); + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-opus-4-5", + }); + + expect(response.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("max"); + expect(session.effortPinnedLevel).toBe("max"); + }); + + it("restores the last applied effort when recommended-value synchronization fails", async () => { + (agent as any).clientCapabilities = { + _meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } }, + }; + const session = agent.sessions[SESSION_ID]; + session.configOptions.find((o) => o.id === "effort")!.currentValue = "low"; + session.appliedEffortLevel = "low"; + session.settingsManager.getSettings = () => ({ effortLevel: "high" }); + applyFlagSettingsSpy.mockRejectedValueOnce(new Error("effort sync failed")); + + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-6", + }); + + expect(response.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("low"); + expect(session.appliedEffortLevel).toBe("low"); + expect(session.effortPinnedLevel).toBeUndefined(); + }); + + it("returns the new model state when effort synchronization fails", async () => { + (agent as any).clientCapabilities = { + _meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } }, + }; + applyFlagSettingsSpy.mockRejectedValueOnce(new Error("effort sync failed")); + + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-6", + }); + + expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6"); + expect(response.configOptions.find((o) => o.id === "model")?.currentValue).toBe( + "claude-sonnet-4-6", + ); + expect(agent.sessions[SESSION_ID].models.currentModelId).toBe("claude-sonnet-4-6"); + }); + + it("publishes the new model state when external-switch effort synchronization fails", async () => { + (agent as any).clientCapabilities = { + _meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } }, + }; + applyFlagSettingsSpy.mockRejectedValueOnce(new Error("effort sync failed")); + const session = agent.sessions[SESSION_ID]; + + await (agent as any).syncModelAfterExternalSwitch(SESSION_ID, session, "claude-sonnet-4-6"); + + expect(setModelSpy).not.toHaveBeenCalled(); + expect(session.models.currentModelId).toBe("claude-sonnet-4-6"); + expect( + sessionUpdates + .filter((notification) => notification.update.sessionUpdate === "config_option_update") + .at(-1)?.update, + ).toMatchObject({ + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({ id: "model", currentValue: "claude-sonnet-4-6" }), + ]), + }); + }); + it("drops effort option when switching to a model without effort support", async () => { // Make sonnet not support effort const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; @@ -670,7 +873,7 @@ describe("session config options", () => { expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null }); // The clamp un-pins: a later switch back re-seeds from settings. - expect(session.effortPinnedByUser).toBe(false); + expect(session.effortPinnedLevel).toBeUndefined(); }); it("adds effort option when switching to a model that supports effort", async () => { @@ -713,7 +916,7 @@ describe("session config options", () => { // pinned, as a user's ACP picker choice would be. const effortOpt = session.configOptions.find((o: any) => o.id === "effort"); if (effortOpt) effortOpt.currentValue = "max"; - session.effortPinnedByUser = true; + session.effortPinnedLevel = "max"; session.modelInfos = [ {