From 8ddcb5ca13efdd4e9bce1c82319744efd02bf846 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Tue, 25 Aug 2026 12:18:26 +0800 Subject: [PATCH] fix(kimi-code): persist picked thinking effort up to the model's default effort (#3205) * fix(kimi-code): persist picked thinking effort up to the model's default effort The persistence gate kept the model's top declared effort session-only unconditionally, so users whose delivered default_effort is the top tier (e.g. max) could never save an explicit pick of it. Compare the pick against the model's default_effort instead, using support_efforts as the strength ordering: picks above the default stay session-only, picks at or below it persist. Models without a declared default keep the historical top-tier rule. The same change lands in the VS Code extension's mirrored logic. * docs(kimi-code): document the effective-default ceiling for effort persistence Clarify in both apps' comments, the changeset, and the config docs that the persistence ceiling is the model's effective default effort, whether declared via the catalog / overrides or synthesized by the protocol profile inference (Claude models resolve to high, so an xhigh pick is session-only there). Pin the inference path with tests in both apps. * fix(vscode): resolve the save-config model with its provider type Mirror the TUI's effectiveModelForHost: without the provider type the Anthropic fallback profile (e.g. claude-latest) never matches, so the inferred default effort that gates persistence was missed and an above-default pick could persist where the TUI keeps it session-only. * Delete .changeset/persist-effort-up-to-model-default.md Signed-off-by: 7Sageer * fix(vscode): seed the persisted effort with the effective-default ceiling * fix(vscode): project webview models with the provider type * fix(kimi-code): apply a session-only effort pick to the runtime in the /provider flow * fix(vscode): update the persisted-effort seed on model-switch saves * docs(kimi-code): drop the default_effort persistence-ceiling note --------- Signed-off-by: 7Sageer --- apps/kimi-code/src/tui/commands/config.ts | 2 +- apps/kimi-code/src/tui/commands/provider.ts | 19 ++- .../src/tui/utils/thinking-config.ts | 33 +++-- .../test/tui/commands/provider.test.ts | 93 +++++++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 104 +++++++++++++++ .../test/tui/utils/thinking-config.test.ts | 73 ++++++++++- apps/vscode/src/handlers/config.handler.ts | 48 +++++-- apps/vscode/test/bridge-handler.test.ts | 68 +++++++++- apps/vscode/test/settings-store.test.ts | 124 +++++++++++++++++- .../webview-ui/src/stores/settings.store.ts | 47 ++++++- 10 files changed, 569 insertions(+), 42 deletions(-) create mode 100644 apps/kimi-code/test/tui/commands/provider.test.ts 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, });