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 = [ {