diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index fca6be328..003ec54b7 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -597,7 +597,7 @@ async function persistModelSelection( const model = host.state.appState.availableModels[alias]; const full = thinkingEffortToConfig( effort, - model === undefined ? undefined : effectiveModelForHost(host, model).supportEfforts, + model === undefined ? undefined : effectiveModelForHost(host, model), ); // Re-confirming the effort shown when the picker opened is not an explicit // choice — persist the model but leave the stored effort preference alone. diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index 417c21dfc..d31c97006 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -307,7 +307,7 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { host.mountEditorReplacement(selector); } -async function setDefaultModel( +export async function setDefaultModel( host: SlashCommandHost, alias: string, effort: ThinkingEffort, @@ -315,16 +315,23 @@ async function setDefaultModel( // Resolve efforts the same way the /model path does (effectiveModelForHost // applies overrides and the protocol-profile inference): catalog entries for // e.g. Anthropic models declare no support_efforts on the alias, and without - // the inference a top-tier pick would slip through as a persisted effort. + // the inference an above-default pick would slip through as a persisted effort. const model = host.state.appState.availableModels[alias]; + const thinking = thinkingEffortToConfig( + effort, + model === undefined ? undefined : effectiveModelForHost(host, model), + ); await host.harness.setConfig({ defaultModel: alias, - thinking: thinkingEffortToConfig( - effort, - model === undefined ? undefined : effectiveModelForHost(host, model).supportEfforts, - ), + thinking, }); await host.authFlow.refreshConfigAfterLogin(); + // refreshConfigAfterLogin reactivates from the persisted config, so a pick + // the gate keeps session-only never reaches the runtime — apply it after + // the refresh, or the persisted value would clobber it. + if (thinking.effort === undefined && effort !== 'off' && effort !== 'on') { + await host.authFlow.activateModelAfterLogin(alias, effort); + } host.track('model_switch', { model: alias }); host.showStatus(`Default model set to ${alias} with thinking ${effort}.`); } diff --git a/apps/kimi-code/src/tui/utils/thinking-config.ts b/apps/kimi-code/src/tui/utils/thinking-config.ts index da3ea1360..79dff4323 100644 --- a/apps/kimi-code/src/tui/utils/thinking-config.ts +++ b/apps/kimi-code/src/tui/utils/thinking-config.ts @@ -1,4 +1,4 @@ -import type { ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; +import type { ModelAlias, ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; /** Whether a thinking effort represents "thinking enabled" (anything but 'off'). */ export function isThinkingOn(effort: ThinkingEffort): boolean { @@ -11,24 +11,37 @@ export function isThinkingOn(effort: ThinkingEffort): boolean { * on-signal rather than a declared effort, so it only persists `enabled` — * boolean models resolve back to `'on'` at runtime via * `defaultThinkingEffortFor`. A concrete effort persists as the global - * default, EXCEPT the model's highest declared level — the last entry of - * `support_efforts` (the list is ordered by strength, the same assumption - * the `middleOf` default-effort resolution makes) — which is session-only - * and records just `enabled`, so the most expensive tier never becomes the - * global default for every new session. When the model's levels are unknown - * the concrete effort is persisted as-is. + * default, EXCEPT when it ranks above the model's effective default + * effort: `support_efforts` is ordered by strength (the same assumption + * the `middleOf` default-effort resolution makes), and a pick more + * expensive than the default stays session-only and records just + * `enabled`, so it never becomes the global default for every new + * session. The default here is the effective model's, however it arose — + * declared via the catalog or `[models.*.overrides]`, or synthesized by + * the protocol-profile inference (`withAnthropicProfile` resolves Claude + * models to 'high', so an 'xhigh' pick stays session-only there). When + * the effective model carries no default effort at all, its highest + * declared level stays session-only (the historical rule). Undeclared + * values persist as-is — the configured provider validates them. */ export function thinkingEffortToConfig( effort: ThinkingEffort, - supportEfforts?: readonly string[], + model?: Pick, ): { enabled: boolean; effort?: string; } { if (effort === 'off') return { enabled: false }; if (effort === 'on') return { enabled: true }; - const top = supportEfforts?.at(-1); - if (top !== undefined && effort === top) return { enabled: true }; + const efforts = model?.supportEfforts; + if (efforts !== undefined && efforts.includes(effort)) { + const declared = model?.defaultEffort; + const ceiling = + declared !== undefined && efforts.includes(declared) + ? efforts.indexOf(declared) + : efforts.length - 2; + if (efforts.indexOf(effort) > ceiling) return { enabled: true }; + } return { enabled: true, effort }; } diff --git a/apps/kimi-code/test/tui/commands/provider.test.ts b/apps/kimi-code/test/tui/commands/provider.test.ts new file mode 100644 index 000000000..92efe4edb --- /dev/null +++ b/apps/kimi-code/test/tui/commands/provider.test.ts @@ -0,0 +1,93 @@ +/** + * Scenario: /provider post-add default-model selection. + * Responsibilities: the picked effort is gated for persistence by the model's + * effective default, and a session-only pick is still applied to the runtime + * after the config refresh (which only reactivates from persisted values). + * Wiring: real setDefaultModel with the harness/authFlow boundaries stubbed by + * a small host rig. + * Run: pnpm -C apps/kimi-code exec vitest run test/tui/commands/provider.test.ts + */ +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import type { SlashCommandHost } from '#/tui/commands'; +import { setDefaultModel } from '#/tui/commands/provider'; + +function makeHost() { + const appState = { + availableModels: { + // Declares no efforts; the Anthropic profile inference supplies + // [low, medium, high, xhigh, max] with the default resolved to 'high'. + opus: { + provider: 'compatible', + model: 'claude-opus-4-7', + maxContextSize: 200_000, + } as unknown as ModelAlias, + }, + availableProviders: { + compatible: { type: 'anthropic' }, + }, + }; + const host = { + state: { appState }, + harness: { + setConfig: vi.fn(async () => ({})), + }, + authFlow: { + refreshConfigAfterLogin: vi.fn(async () => {}), + activateModelAfterLogin: vi.fn(async () => {}), + }, + track: vi.fn(), + showStatus: vi.fn(), + } as unknown as SlashCommandHost & { + harness: { setConfig: ReturnType }; + authFlow: { + refreshConfigAfterLogin: ReturnType; + activateModelAfterLogin: ReturnType; + }; + }; + return { host }; +} + +describe('setDefaultModel', () => { + it('applies an above-default pick to the runtime when the gate keeps it session-only', async () => { + const { host } = makeHost(); + + await setDefaultModel(host, 'opus', 'xhigh'); + + expect(host.harness.setConfig).toHaveBeenCalledWith({ + defaultModel: 'opus', + thinking: { enabled: true }, + }); + expect(host.authFlow.activateModelAfterLogin).toHaveBeenCalledWith('opus', 'xhigh'); + // The application must come after the refresh, or the persisted value + // reactivated by refreshConfigAfterLogin would clobber the pick. + expect( + host.authFlow.activateModelAfterLogin.mock.invocationCallOrder[0]!, + ).toBeGreaterThan(host.authFlow.refreshConfigAfterLogin.mock.invocationCallOrder[0]!); + }); + + it('does not re-apply the effort when the pick persists', async () => { + const { host } = makeHost(); + + await setDefaultModel(host, 'opus', 'high'); + + expect(host.harness.setConfig).toHaveBeenCalledWith({ + defaultModel: 'opus', + thinking: { enabled: true, effort: 'high' }, + }); + expect(host.authFlow.activateModelAfterLogin).not.toHaveBeenCalled(); + }); + + it('does not re-apply a boolean on pick', async () => { + const { host } = makeHost(); + + await setDefaultModel(host, 'opus', 'on'); + + expect(host.harness.setConfig).toHaveBeenCalledWith({ + defaultModel: 'opus', + thinking: { enabled: true }, + }); + expect(host.authFlow.activateModelAfterLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 73b2c33b5..5e56a7aea 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -7720,6 +7720,110 @@ command = "vim" }); }); + it('persists max when the model default effort is max', async () => { + let switched = false; + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: switched ? 'max' : 'high', + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + setThinking: vi.fn(async () => { + switched = true; + }), + }); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'max', + }, + }, + defaultModel: 'k2', + // A previously stored effort keeps the runtime below the delivered + // max default, so picking max is an explicit change. + thinking: { enabled: true, effort: 'high' }, + })), + setConfig, + }); + + driver.handleUserInput('/effort max'); + + await vi.waitFor(() => { + expect(session.setThinking).toHaveBeenCalledWith('max'); + }); + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'k2', + thinking: { enabled: true, effort: 'max' }, + }); + }); + expect(driver.state.appState.thinkingEffort).toBe('max'); + }); + + it('keeps an xhigh pick session-only for a Claude model via the profile inference', async () => { + // claude-opus-4-7 declares no efforts; the Anthropic profile inference + // supplies [low, medium, high, xhigh, max] and resolves the default to + // 'high', so an xhigh pick ranks above the persistence ceiling. + let switched = false; + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: 'opus', + thinkingEffort: switched ? 'xhigh' : 'high', + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + setThinking: vi.fn(async () => { + switched = true; + }), + }); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'anthropic', apiKey: 'test-key' }, + }, + models: { + opus: { + provider: 'compatible', + model: 'claude-opus-4-7', + maxContextSize: 100, + }, + }, + defaultModel: 'opus', + thinking: { enabled: true, effort: 'high' }, + })), + setConfig, + }); + + driver.handleUserInput('/effort xhigh'); + + await vi.waitFor(() => { + expect(session.setThinking).toHaveBeenCalledWith('xhigh'); + }); + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'opus', + thinking: { enabled: true }, + }); + }); + expect(driver.state.appState.thinkingEffort).toBe('xhigh'); + }); + it('refreshes only OAuth provider models before opening /model picker', async () => { const { driver } = await makeDriver(makeSession(), { getConfig: vi.fn(async () => ({ diff --git a/apps/kimi-code/test/tui/utils/thinking-config.test.ts b/apps/kimi-code/test/tui/utils/thinking-config.test.ts index e0a953595..fd41b7668 100644 --- a/apps/kimi-code/test/tui/utils/thinking-config.test.ts +++ b/apps/kimi-code/test/tui/utils/thinking-config.test.ts @@ -21,20 +21,85 @@ describe('thinkingEffortToConfig', () => { }); it.each([ - // The model's highest declared level (last support_efforts entry) is + // With no declared default effort, the historical rule applies: the + // model's highest declared level (last support_efforts entry) is // session-only; anything below it persists as the global default. ['low', { enabled: true, effort: 'low' }], ['high', { enabled: true, effort: 'high' }], ['max', { enabled: true }], // Undeclared values persist as-is (the provider validates them). ['ultra', { enabled: true, effort: 'ultra' }], - ] as const)('maps %s → %o for [low, high, max]', (effort, expected) => { - expect(thinkingEffortToConfig(effort, ['low', 'high', 'max'])).toEqual(expected); + ] as const)('maps %s → %o for [low, high, max] without a default', (effort, expected) => { + expect(thinkingEffortToConfig(effort, { supportEfforts: ['low', 'high', 'max'] })).toEqual( + expected, + ); }); it('treats a single declared level as the top tier', () => { - expect(thinkingEffortToConfig('max', ['max'])).toEqual({ enabled: true }); + expect(thinkingEffortToConfig('max', { supportEfforts: ['max'] })).toEqual({ enabled: true }); }); + + it.each([ + ['low', { enabled: true, effort: 'low' }], + ['high', { enabled: true, effort: 'high' }], + // Above the delivered default: session-only. + ['max', { enabled: true }], + ] as const)('maps %s → %o for [low, high, max] with default high', (effort, expected) => { + expect( + thinkingEffortToConfig(effort, { + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', + }), + ).toEqual(expected); + }); + + it('persists the top tier when the delivered default is the top tier', () => { + expect( + thinkingEffortToConfig('max', { + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'max', + }), + ).toEqual({ enabled: true, effort: 'max' }); + }); + + it('keeps a non-top pick above the delivered default session-only', () => { + expect( + thinkingEffortToConfig('high', { + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'low', + }), + ).toEqual({ enabled: true }); + }); + + it('falls back to the top-tier rule when the declared default is not a listed level', () => { + expect( + thinkingEffortToConfig('max', { + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'ultra', + }), + ).toEqual({ enabled: true }); + }); + + it.each([ + ['low', { enabled: true, effort: 'low' }], + ['medium', { enabled: true, effort: 'medium' }], + ['high', { enabled: true, effort: 'high' }], + // Above the effective default: session-only. + ['xhigh', { enabled: true }], + ['max', { enabled: true }], + ] as const)( + // The shape the Anthropic profile inference hands the gate for the + // latest Claude models: five tiers with the default resolved to 'high'. + 'maps %s → %o for [low, medium, high, xhigh, max] with default high', + (effort, expected) => { + expect( + thinkingEffortToConfig(effort, { + supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], + defaultEffort: 'high', + }), + ).toEqual(expected); + }, + ); }); describe('isThinkingOn', () => { diff --git a/apps/vscode/src/handlers/config.handler.ts b/apps/vscode/src/handlers/config.handler.ts index 079c81670..87d9b7dcf 100644 --- a/apps/vscode/src/handlers/config.handler.ts +++ b/apps/vscode/src/handlers/config.handler.ts @@ -3,6 +3,7 @@ import { effectiveModelAlias, type KimiConfig as SdkKimiConfig, type ModelAlias, + type ProviderType, type ThinkingEffort, } from "@moonshot-ai/kimi-code-sdk"; @@ -41,9 +42,14 @@ const saveConfig: Handler = async (params, ctx) const effortChanged = params.effortChanged !== false; const config = await ctx.harness.getConfig({ reload: true }); const model = config.models?.[params.model]; + // Resolve with the provider type the way the TUI's effectiveModelForHost + // does: without it the Anthropic fallback profile (e.g. `claude-latest`) + // never matches, so the inferred default that gates persistence is missed. + const providerType = + model === undefined ? undefined : (config.providers?.[model.provider]?.type ?? model.protocol); const full = thinkingConfig( effort, - model === undefined ? undefined : effectiveModelAlias(model).supportEfforts, + model === undefined ? undefined : effectiveModelAlias(model, providerType), ); // Re-confirming the effort already shown is not an explicit choice — // persist the model but leave the stored effort preference alone (the TUI's @@ -126,7 +132,12 @@ export const configHandlers = { export function toWebviewConfig(config: SdkKimiConfig): WebviewKimiConfig { const models: ModelConfig[] = Object.entries(config.models ?? {}) - .map(([id, model]) => toWebviewModel(id, model)) + // Resolve with the provider type the way saveConfig does: without it the + // Anthropic fallback profile never matches, and the webview's effort + // persistence seed would gate on a different effective model. + .map(([id, model]) => + toWebviewModel(id, model, config.providers?.[model.provider]?.type ?? model.protocol), + ) .toSorted((left, right) => left.name.localeCompare(right.name)); return { defaultModel: config.defaultModel ?? models[0]?.id ?? null, @@ -136,8 +147,8 @@ export function toWebviewConfig(config: SdkKimiConfig): WebviewKimiConfig { }; } -function toWebviewModel(id: string, model: ModelAlias): ModelConfig { - const effective = effectiveModelAlias(model); +function toWebviewModel(id: string, model: ModelAlias, providerType?: ProviderType): ModelConfig { + const effective = effectiveModelAlias(model, providerType); return { id, name: effective.displayName ?? effective.model ?? id, @@ -159,20 +170,33 @@ function sessionConfigEffort(config: SessionConfig): ThinkingEffort { * Project a thinking effort to the `[thinking]` config patch persisted to * config.toml — mirrors the TUI's thinkingEffortToConfig. "off" disables * thinking; "on" is the boolean-model on-signal, so it only persists - * `enabled`. A concrete effort persists as the global default, EXCEPT the - * model's highest declared level — the last entry of `support_efforts` — - * which is session-only and records just `enabled`, so the most expensive - * tier never becomes the global default for every new session. When the - * model's levels are unknown the concrete effort is persisted as-is. + * `enabled`. A concrete effort persists as the global default, EXCEPT when it + * ranks above the model's effective default effort: `support_efforts` is + * ordered by strength, and a pick more expensive than the default stays + * session-only and records just `enabled`, so it never becomes the global + * default for every new session. The default here is the effective model's, + * however it arose — declared via the catalog or overrides, or synthesized + * by the protocol-profile inference (`withAnthropicProfile` resolves Claude + * models to "high", so an "xhigh" pick stays session-only there). When the + * effective model carries no default effort at all, its highest declared + * level stays session-only (the historical rule). When the model's levels + * are unknown the concrete effort is persisted as-is. */ function thinkingConfig( effort: ThinkingEffort, - supportEfforts?: readonly string[], + model?: Pick, ): { enabled: boolean; effort?: string } { if (effort === "off") return { enabled: false }; if (effort === "on") return { enabled: true }; - const top = supportEfforts?.at(-1); - if (top !== undefined && effort === top) return { enabled: true }; + const efforts = model?.supportEfforts; + if (efforts !== undefined && efforts.includes(effort)) { + const declared = model?.defaultEffort; + const ceiling = + declared !== undefined && efforts.includes(declared) + ? efforts.indexOf(declared) + : efforts.length - 2; + if (efforts.indexOf(effort) > ceiling) return { enabled: true }; + } return { enabled: true, effort }; } diff --git a/apps/vscode/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts index 9a6023e37..5c6ca4297 100644 --- a/apps/vscode/test/bridge-handler.test.ts +++ b/apps/vscode/test/bridge-handler.test.ts @@ -293,6 +293,36 @@ describe("Webview RPC boundary (validates requests before host dispatch)", () => }); }); + it("resolves the fallback-profile default effort with the provider type", async () => { + // claude-latest declares efforts but no default; the Anthropic fallback + // profile only matches when the provider type joins the resolution. + host.harness.getConfig.mockResolvedValueOnce({ + defaultModel: "custom/claude", + providers: { + custom: { type: "anthropic", apiKey: "test-key" }, + }, + models: { + "custom/claude": { + provider: "custom", + model: "claude-latest", + supportEfforts: ["low", "medium", "high", "xhigh", "max"], + }, + }, + }); + + const result = await bridge.handle({ id: "rpc-models", method: Methods.GetModels }, "view-1"); + + expect(result).toMatchObject({ + result: { + models: [{ + id: "custom/claude", + support_efforts: ["low", "medium", "high", "xhigh", "max"], + default_effort: "high", + }], + }, + }); + }); + it("does not expose the session storage path when listing sessions", async () => { host.harness.listSessions.mockResolvedValueOnce([ { @@ -502,7 +532,7 @@ describe("Webview config saves (thinking effort persistence parity with the TUI) }); }); - it("keeps the model's top declared tier session-only", async () => { + it("keeps a pick above the model's delivered default session-only", async () => { mockConfig(); await bridge.handle( @@ -516,6 +546,42 @@ describe("Webview config saves (thinking effort persistence parity with the TUI) }); }); + it("persists the top tier when the model's delivered default is the top tier", async () => { + host.harness.getConfig.mockResolvedValue({ + defaultModel: "kimi/reasoning", + models: { "kimi/reasoning": { ...effortModel, defaultEffort: "max" } }, + } as never); + + await bridge.handle( + { id: "rpc-1", method: Methods.SaveConfig, params: { model: "kimi/reasoning", thinking: true, effort: "max" } }, + "view-1", + ); + + expect(host.harness.setConfig).toHaveBeenCalledWith({ + defaultModel: "kimi/reasoning", + thinking: { enabled: true, effort: "max" }, + }); + }); + + it("keeps an xhigh pick session-only when the default comes from the Anthropic profile inference", async () => { + // claude-opus-4-7 declares no efforts; the profile inference supplies + // [low, medium, high, xhigh, max] and resolves the default to "high". + host.harness.getConfig.mockResolvedValue({ + defaultModel: "custom/claude", + models: { "custom/claude": { provider: "custom", model: "claude-opus-4-7" } }, + } as never); + + await bridge.handle( + { id: "rpc-1", method: Methods.SaveConfig, params: { model: "custom/claude", thinking: true, effort: "xhigh" } }, + "view-1", + ); + + expect(host.harness.setConfig).toHaveBeenCalledWith({ + defaultModel: "custom/claude", + thinking: { enabled: true }, + }); + }); + it("persists the concrete effort when the model's levels are unknown", async () => { host.harness.getConfig.mockResolvedValue({ defaultModel: "other/model", models: {} }); diff --git a/apps/vscode/test/settings-store.test.ts b/apps/vscode/test/settings-store.test.ts index 93d0decad..38af845f2 100644 --- a/apps/vscode/test/settings-store.test.ts +++ b/apps/vscode/test/settings-store.test.ts @@ -384,7 +384,7 @@ describe("Webview thinking effort parity with the TUI", () => { expect(boundary.saveConfig).not.toHaveBeenCalled(); }); - it("does not seed future sessions with the model's top declared tier", () => { + it("seeds the top tier when it is the model's delivered default", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); useSettingsStore.getState().initModels(MODELS, "reasoning", false); @@ -392,6 +392,128 @@ describe("Webview thinking effort parity with the TUI", () => { expect(useSettingsStore.getState().thinkingEffort).toBe("high"); expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "reasoning", thinking: true, effort: "high" }); + expect(useSettingsStore.getState().defaultThinkingEffort).toBe("high"); + }); + + it("does not seed a pick above the model's delivered default", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels([ + { + id: "reasoning", + name: "Reasoning", + provider: "managed:kimi-code", + capabilities: ["thinking"], + support_efforts: ["low", "high", "max"], + default_effort: "low", + }, + ], "reasoning", false); + + useSettingsStore.getState().selectThinkingEffort("high"); + + expect(useSettingsStore.getState().thinkingEffort).toBe("high"); + expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "reasoning", thinking: true, effort: "high" }); + expect(useSettingsStore.getState().defaultThinkingEffort).toBeUndefined(); + }); + + it("does not seed the top tier when the model declares no default", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels([ + { + id: "reasoning", + name: "Reasoning", + provider: "managed:kimi-code", + capabilities: ["thinking"], + support_efforts: ["low", "high"], + }, + ], "reasoning", false); + + useSettingsStore.getState().selectThinkingEffort("high"); + + expect(useSettingsStore.getState().thinkingEffort).toBe("high"); + expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "reasoning", thinking: true, effort: "high" }); + expect(useSettingsStore.getState().defaultThinkingEffort).toBeUndefined(); + }); + + const SWITCH_MODELS = [ + { + id: "seeded", + name: "Seeded", + provider: "managed:kimi-code", + capabilities: ["thinking"], + support_efforts: ["low", "medium"], + default_effort: "medium", + }, + { + id: "max-default", + name: "Max Default", + provider: "managed:kimi-code", + capabilities: ["thinking"], + support_efforts: ["low", "max"], + default_effort: "max", + }, + ]; + + it("updates the seed when a model switch persists the derived effort", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels(SWITCH_MODELS, "seeded", true, "medium"); + + // "medium" is unsupported here, so the switch derives the model default + // "max"; with the delivered default at the top tier the host persists it. + useSettingsStore.getState().updateModel("max-default"); + + expect(useSettingsStore.getState().thinkingEffort).toBe("max"); + expect(boundary.saveConfig).toHaveBeenCalledWith({ + model: "max-default", + thinking: true, + effort: "max", + effortChanged: true, + }); + expect(useSettingsStore.getState().defaultThinkingEffort).toBe("max"); + }); + + it("rolls the seed back when the model-switch save fails", async () => { + let rejectSave!: (error: Error) => void; + boundary.saveConfig.mockReturnValue(new Promise((_resolve, reject) => { + rejectSave = reject; + })); + useSettingsStore.getState().initModels(SWITCH_MODELS, "seeded", true, "medium"); + + useSettingsStore.getState().updateModel("max-default"); + expect(useSettingsStore.getState().defaultThinkingEffort).toBe("max"); + + rejectSave(new Error("config.toml is read-only")); + await vi.waitFor(() => { + expect(useSettingsStore.getState().defaultThinkingEffort).toBe("medium"); + }); + }); + + it("leaves the seed alone when the switch re-confirms the active effort", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + // No persisted effort: the seed starts undefined and the session derives + // "max" from the model default. + useSettingsStore.getState().initModels([ + ...SWITCH_MODELS, + { + id: "max-default-b", + name: "Max Default B", + provider: "managed:kimi-code", + capabilities: ["thinking"], + support_efforts: ["low", "max"], + default_effort: "max", + }, + ], "max-default", true); + + // The derived effort equals the active one, so the host leaves the stored + // preference untouched — the seed must not invent one either. + useSettingsStore.getState().updateModel("max-default-b"); + + expect(useSettingsStore.getState().thinkingEffort).toBe("max"); + expect(boundary.saveConfig).toHaveBeenCalledWith({ + model: "max-default-b", + thinking: true, + effort: "max", + effortChanged: false, + }); expect(useSettingsStore.getState().defaultThinkingEffort).toBeUndefined(); }); diff --git a/apps/vscode/webview-ui/src/stores/settings.store.ts b/apps/vscode/webview-ui/src/stores/settings.store.ts index 4c8c4e7a1..9eed4e8b2 100644 --- a/apps/vscode/webview-ui/src/stores/settings.store.ts +++ b/apps/vscode/webview-ui/src/stores/settings.store.ts @@ -102,6 +102,23 @@ function defaultEffortForModel(model: ModelConfig, defaultThinking: boolean, con return defaultThinking ? "on" : "off"; } +/** + * Whether picking `effort` persists it as the global default — mirrors the + * extension host's thinkingConfig gate: a pick above the model's effective + * default effort stays session-only, with the ceiling falling back to the + * tier below the top when the model carries no listed default. Only listed + * efforts reach this helper (selectThinkingEffort rejects the rest). + */ +function persistsAsDefaultEffort(model: ModelConfig, effort: string): boolean { + const efforts = model.support_efforts ?? []; + const declared = model.default_effort; + const ceiling = + declared !== undefined && efforts.includes(declared) + ? efforts.indexOf(declared) + : efforts.length - 2; + return efforts.indexOf(effort) <= ceiling; +} + export function isImageModel(model: ModelConfig): boolean { return model.capabilities.includes("image_in"); } @@ -203,15 +220,29 @@ export const useSettingsStore = create((set, get) => ({ } const thinkingEffort = defaultEffortForModel(model, defaultThinking, defaultThinkingEffort); - set({ currentModel: modelId, thinkingEffort }); + const effortChanged = thinkingEffort !== previousEffort; + set({ + currentModel: modelId, + thinkingEffort, + // The save below persists the derived effort when it changed and + // clears the gate — keep the seed in sync, or the next switch derives + // from a stale value and saves it back over the persisted one. + defaultThinkingEffort: + effortChanged && + thinkingEffort !== "off" && + thinkingEffort !== "on" && + persistsAsDefaultEffort(model, thinkingEffort) + ? thinkingEffort + : defaultThinkingEffort, + }); saveConfigWithRollback( { model: modelId, thinking: thinkingEffort !== "off", effort: thinkingEffort, - effortChanged: thinkingEffort !== previousEffort, + effortChanged, }, - { currentModel, thinkingEffort: previousEffort }, + { currentModel, thinkingEffort: previousEffort, defaultThinkingEffort }, set, ); }, @@ -258,11 +289,13 @@ export const useSettingsStore = create((set, get) => ({ set({ thinkingEffort, defaultThinking: thinkingEffort !== "off", - // The model's top declared tier is session-only (only the boolean - // toggle is persisted), so it must not become the configured-effort - // seed for future sessions. + // A pick above the model's effective default effort is session-only + // (only the boolean toggle is persisted), so it must not become the + // configured-effort seed for future sessions. defaultThinkingEffort: - thinkingEffort !== "off" && thinkingEffort !== "on" && thinkingEffort !== allowed.at(-1) + thinkingEffort !== "off" && + thinkingEffort !== "on" && + persistsAsDefaultEffort(model, thinkingEffort) ? thinkingEffort : defaultThinkingEffort, });