From e77211c2452d72cedef22b29c7bd7f23d79f8db8 Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 20 Jul 2026 18:05:31 +0800 Subject: [PATCH] feat(vscode): scope thinking effort to the current session --- apps/vscode/shared/bridge.ts | 3 +- apps/vscode/shared/types.ts | 7 ++ apps/vscode/src/handlers/config.handler.ts | 42 +++++++-- apps/vscode/test/bridge-handler.test.ts | 86 +++++++++++++++++++ apps/vscode/test/settings-store.test.ts | 45 ++++++++++ .../src/components/ThinkingButton.tsx | 10 ++- .../src/components/inputarea/InputArea.tsx | 17 +++- .../webview-ui/src/stores/settings.store.ts | 18 +++- 8 files changed, 218 insertions(+), 10 deletions(-) diff --git a/apps/vscode/shared/bridge.ts b/apps/vscode/shared/bridge.ts index 805c1f32f6..d7f9bd88ae 100644 --- a/apps/vscode/shared/bridge.ts +++ b/apps/vscode/shared/bridge.ts @@ -156,7 +156,8 @@ function validateParams(method: RpcMethod, params: unknown): boolean { return isPlainObject(params) && typeof params["model"] === "string" && isOptionalType(params["thinking"], "boolean") - && isOptionalType(params["effort"], "string"); + && isOptionalType(params["effort"], "string") + && isOptionalType(params["effortChanged"], "boolean"); case Methods.AddMCPServer: return isMcpServerConfig(params); case Methods.UpdateMCPServer: diff --git a/apps/vscode/shared/types.ts b/apps/vscode/shared/types.ts index 2f6c56d610..a16351cfde 100644 --- a/apps/vscode/shared/types.ts +++ b/apps/vscode/shared/types.ts @@ -4,6 +4,13 @@ export interface SessionConfig { model: string; thinking?: boolean; effort?: string; + /** + * Whether the user explicitly changed the effort. Re-confirming the effort + * already shown is not an explicit choice: the model is persisted but the + * stored effort preference is left alone (mirrors the TUI's + * persistModelSelection). Treated as true when omitted. + */ + effortChanged?: boolean; } export interface ProjectFile { diff --git a/apps/vscode/src/handlers/config.handler.ts b/apps/vscode/src/handlers/config.handler.ts index 1e811ef6d4..079c81670e 100644 --- a/apps/vscode/src/handlers/config.handler.ts +++ b/apps/vscode/src/handlers/config.handler.ts @@ -38,10 +38,27 @@ const SLASH_COMMANDS: SlashCommandInfo[] = [ const saveConfig: Handler = async (params, ctx) => { const effort = sessionConfigEffort(params); - await ctx.harness.setConfig({ - defaultModel: params.model, - thinking: thinkingConfig(effort), - }); + const effortChanged = params.effortChanged !== false; + const config = await ctx.harness.getConfig({ reload: true }); + const model = config.models?.[params.model]; + const full = thinkingConfig( + effort, + model === undefined ? undefined : effectiveModelAlias(model).supportEfforts, + ); + // Re-confirming the effort already shown is not an explicit choice — + // persist the model but leave the stored effort preference alone (the TUI's + // persistModelSelection rule). + const patch = effortChanged ? full : { enabled: full.enabled }; + if ( + config.defaultModel !== params.model + || config.thinking?.enabled !== patch.enabled + || (effortChanged && config.thinking?.effort !== patch.effort) + ) { + await ctx.harness.setConfig({ + defaultModel: params.model, + thinking: patch, + }); + } const runtime = ctx.getSession(); if (runtime !== undefined) { @@ -138,9 +155,24 @@ function sessionConfigEffort(config: SessionConfig): ThinkingEffort { return config.thinking === true ? "on" : "off"; } -function thinkingConfig(effort: ThinkingEffort): { enabled: boolean; effort?: string } { +/** + * 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. + */ +function thinkingConfig( + effort: ThinkingEffort, + supportEfforts?: readonly string[], +): { 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 }; return { enabled: true, effort }; } diff --git a/apps/vscode/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts index add81ea091..51b203823c 100644 --- a/apps/vscode/test/bridge-handler.test.ts +++ b/apps/vscode/test/bridge-handler.test.ts @@ -25,6 +25,7 @@ const host = vi.hoisted(() => { homeDir: "/tmp/kimi-code-test-home", close: vi.fn(async () => undefined), getConfig: vi.fn(), + setConfig: vi.fn(async () => undefined), listSessions: vi.fn(async () => []), resumeSession: vi.fn(), forkSession: vi.fn(), @@ -427,6 +428,91 @@ describe("Webview RPC boundary (validates requests before host dispatch)", () => }); }); +describe("Webview config saves (thinking effort persistence parity with the TUI)", () => { + const effortModel = { + provider: "managed:kimi-code", + model: "reasoning", + supportEfforts: ["low", "high", "max"], + defaultEffort: "high", + }; + + function mockConfig(thinking?: { enabled: boolean; effort?: string }) { + host.harness.getConfig.mockResolvedValue({ + defaultModel: "kimi/reasoning", + thinking, + models: { "kimi/reasoning": effortModel }, + } as never); + } + + it("persists a non-top effort as the global default", async () => { + mockConfig(); + + const result = await bridge.handle( + { id: "rpc-1", method: Methods.SaveConfig, params: { model: "kimi/reasoning", thinking: true, effort: "high" } }, + "view-1", + ); + + expect(result).toEqual({ id: "rpc-1", result: { ok: true } }); + expect(host.harness.setConfig).toHaveBeenCalledWith({ + defaultModel: "kimi/reasoning", + thinking: { enabled: true, effort: "high" }, + }); + }); + + it("keeps the model's top declared tier session-only", async () => { + mockConfig(); + + 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 }, + }); + }); + + it("persists the concrete effort when the model's levels are unknown", async () => { + host.harness.getConfig.mockResolvedValue({ defaultModel: "other/model", models: {} }); + + await bridge.handle( + { id: "rpc-1", method: Methods.SaveConfig, params: { model: "custom/model", thinking: true, effort: "max" } }, + "view-1", + ); + + expect(host.harness.setConfig).toHaveBeenCalledWith({ + defaultModel: "custom/model", + thinking: { enabled: true, effort: "max" }, + }); + }); + + it("leaves the stored effort alone when the pick re-confirms the active effort", async () => { + mockConfig({ enabled: false, effort: "low" }); + + await bridge.handle( + { id: "rpc-1", method: Methods.SaveConfig, params: { model: "kimi/reasoning", thinking: true, effort: "high", effortChanged: false } }, + "view-1", + ); + + expect(host.harness.setConfig).toHaveBeenCalledWith({ + defaultModel: "kimi/reasoning", + thinking: { enabled: true }, + }); + }); + + it("skips the config write entirely when nothing changed", async () => { + mockConfig({ enabled: true, effort: "high" }); + + await bridge.handle( + { id: "rpc-1", method: Methods.SaveConfig, params: { model: "kimi/reasoning", thinking: true, effort: "high" } }, + "view-1", + ); + + expect(host.harness.setConfig).not.toHaveBeenCalled(); + }); +}); + function createResumedSession(id: string, workDir: string) { const close = vi.fn(async () => undefined); const summary = { diff --git a/apps/vscode/test/settings-store.test.ts b/apps/vscode/test/settings-store.test.ts index b830e998b1..93d0decad8 100644 --- a/apps/vscode/test/settings-store.test.ts +++ b/apps/vscode/test/settings-store.test.ts @@ -96,6 +96,7 @@ describe("Webview model settings persistence", () => { model: "proxy/shared", thinking: false, effort: "off", + effortChanged: false, }); }); @@ -370,6 +371,50 @@ describe("Webview thinking effort parity with the TUI", () => { expect(useSettingsStore.getState().thinkingEffort).toBe(previous); expect(boundary.saveConfig).not.toHaveBeenCalled(); }); + + it("skips the config write when re-confirming the current effort", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels(MODELS, "reasoning", true); + expect(useSettingsStore.getState().thinkingEffort).toBe("high"); + boundary.saveConfig.mockClear(); + + useSettingsStore.getState().selectThinkingEffort("high"); + + expect(useSettingsStore.getState().thinkingEffort).toBe("high"); + expect(boundary.saveConfig).not.toHaveBeenCalled(); + }); + + it("does not seed future sessions with the model's top declared tier", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels(MODELS, "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("seeds future sessions with a persisted non-top effort", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels(MODELS, "reasoning", false); + + useSettingsStore.getState().selectThinkingEffort("low"); + + expect(useSettingsStore.getState().thinkingEffort).toBe("low"); + expect(useSettingsStore.getState().defaultThinkingEffort).toBe("low"); + }); + + it("marks the effort as changed when a model switch resolves to a different effort", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels(MODELS, "reasoning", true); + expect(useSettingsStore.getState().thinkingEffort).toBe("high"); + + useSettingsStore.getState().updateModel("always"); + + expect(useSettingsStore.getState().thinkingEffort).toBe("on"); + expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "always", thinking: true, effort: "on", effortChanged: true }); + }); }); describe("Webview mid-turn warnings", () => { diff --git a/apps/vscode/webview-ui/src/components/ThinkingButton.tsx b/apps/vscode/webview-ui/src/components/ThinkingButton.tsx index f71cb59b29..236d53fcdb 100644 --- a/apps/vscode/webview-ui/src/components/ThinkingButton.tsx +++ b/apps/vscode/webview-ui/src/components/ThinkingButton.tsx @@ -11,6 +11,9 @@ interface ThinkingButtonProps { efforts?: string[]; alwaysOn?: boolean; disabled?: boolean; + /** When set, rendered as a muted wrapping note below the effort options + * (e.g. the mid-conversation switch cache-cost notice). */ + cacheNote?: string; onToggle: () => void; onSelectEffort: (effort: string) => void; } @@ -19,7 +22,7 @@ function label(effort: string): string { return effort.charAt(0).toUpperCase() + effort.slice(1); } -export function ThinkingButton({ mode, effort, efforts = [], alwaysOn = false, disabled, onToggle, onSelectEffort }: ThinkingButtonProps) { +export function ThinkingButton({ mode, effort, efforts = [], alwaysOn = false, disabled, cacheNote, onToggle, onSelectEffort }: ThinkingButtonProps) { if (mode === "none") return null; const active = effort !== "off" || alwaysOn; @@ -57,6 +60,11 @@ export function ThinkingButton({ mode, effort, efforts = [], alwaysOn = false, d {label(option)} ))} + {cacheNote !== undefined && ( +
+ {cacheNote} +
+ )} ); diff --git a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx index 5d1b09cd64..3151005a77 100644 --- a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx +++ b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx @@ -43,6 +43,9 @@ interface InputAreaProps { onAuthAction?: () => void; } +const SWITCH_CACHE_NOTE = + "Note: Switching models or thinking effort invalidates the existing prompt cache. Start a new conversation to avoid extra token costs."; + export function InputArea({ onAuthAction }: InputAreaProps) { const textareaRef = useRef(null); const menuRef = useRef(null); @@ -50,11 +53,14 @@ export function InputArea({ onAuthAction }: InputAreaProps) { const [cursorPos, setCursorPos] = useState(0); const [previewMedia, setPreviewMedia] = useState(null); - const { isStreaming, sendMessage, abort, draftMedia, removeDraftMedia, hasProcessingMedia, getMediaInConversation, pendingInput, planMode } = useChatStore(); + const { isStreaming, sendMessage, abort, draftMedia, removeDraftMedia, hasProcessingMedia, getMediaInConversation, pendingInput, planMode, messages } = useChatStore(); const { currentModel, thinkingEffort, updateModel, toggleThinking, selectThinkingEffort, models, extensionConfig, getCurrentThinkingMode } = useSettingsStore(); const isProcessing = hasProcessingMedia(); const thinkingMode = getCurrentThinkingMode(); + // A switch from a non-empty conversation resends the accumulated context, + // losing the prompt cache — surface the cost note in the switcher dropdowns. + const hasConversationHistory = messages.some((message) => message.role === "user"); const [showPlanModeConfirm, setShowPlanModeConfirm] = useState(false); @@ -447,6 +453,14 @@ export function InputArea({ onAuthAction }: InputAreaProps) { {showProviderGroups && groupIndex < modelGroups.length - 1 && } ))} + {hasConversationHistory && ( + <> + +
+ {SWITCH_CACHE_NOTE} +
+ + )} @@ -456,6 +470,7 @@ export function InputArea({ onAuthAction }: InputAreaProps) { efforts={currentModelConfig?.support_efforts} alwaysOn={currentModelConfig?.capabilities.includes("always_thinking")} disabled={isStreaming} + cacheNote={hasConversationHistory ? SWITCH_CACHE_NOTE : undefined} onToggle={toggleThinking} onSelectEffort={selectThinkingEffort} /> diff --git a/apps/vscode/webview-ui/src/stores/settings.store.ts b/apps/vscode/webview-ui/src/stores/settings.store.ts index 564dd94079..4c8c4e7a18 100644 --- a/apps/vscode/webview-ui/src/stores/settings.store.ts +++ b/apps/vscode/webview-ui/src/stores/settings.store.ts @@ -205,7 +205,12 @@ export const useSettingsStore = create((set, get) => ({ const thinkingEffort = defaultEffortForModel(model, defaultThinking, defaultThinkingEffort); set({ currentModel: modelId, thinkingEffort }); saveConfigWithRollback( - { model: modelId, thinking: thinkingEffort !== "off", effort: thinkingEffort }, + { + model: modelId, + thinking: thinkingEffort !== "off", + effort: thinkingEffort, + effortChanged: thinkingEffort !== previousEffort, + }, { currentModel, thinkingEffort: previousEffort }, set, ); @@ -247,10 +252,19 @@ export const useSettingsStore = create((set, get) => ({ const alwaysOn = model.capabilities.includes("always_thinking"); if (thinkingEffort !== "off" && thinkingEffort !== "on" && !allowed.includes(thinkingEffort)) return; if (alwaysOn && thinkingEffort === "off") return; + // Re-confirming the effort already shown is not an explicit choice — + // skip the state update and the config write entirely. + if (thinkingEffort === previousEffort) return; set({ thinkingEffort, defaultThinking: thinkingEffort !== "off", - defaultThinkingEffort: thinkingEffort !== "off" && thinkingEffort !== "on" ? thinkingEffort : undefined, + // 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. + defaultThinkingEffort: + thinkingEffort !== "off" && thinkingEffort !== "on" && thinkingEffort !== allowed.at(-1) + ? thinkingEffort + : defaultThinkingEffort, }); saveConfigWithRollback( { model: currentModel, thinking: thinkingEffort !== "off", effort: thinkingEffort },