diff --git a/.changeset/preserve-model-variants.md b/.changeset/preserve-model-variants.md new file mode 100644 index 00000000000..10941827bb8 --- /dev/null +++ b/.changeset/preserve-model-variants.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve the selected reasoning effort when switching to a model that supports the same or nearest available variant. diff --git a/packages/kilo-vscode/tests/unit/mode-model.test.ts b/packages/kilo-vscode/tests/unit/mode-model.test.ts index 9710bde9698..55f9a43f85b 100644 --- a/packages/kilo-vscode/tests/unit/mode-model.test.ts +++ b/packages/kilo-vscode/tests/unit/mode-model.test.ts @@ -13,8 +13,15 @@ describe("modelPatch", () => { }) }) - it("clears stale variant when next model does not support it", () => { + it("keeps the nearest supported effort when the exact variant is unavailable", () => { expect(modelPatch("kilo", "anthropic/claude-sonnet-4-6", ["low", "medium"], "high")).toEqual({ + model: "kilo/anthropic/claude-sonnet-4-6", + variant: "medium", + }) + }) + + it("clears an unknown variant when next model does not support it", () => { + expect(modelPatch("kilo", "anthropic/claude-sonnet-4-6", ["low", "medium"], "thinking")).toEqual({ model: "kilo/anthropic/claude-sonnet-4-6", variant: null, }) diff --git a/packages/kilo-vscode/tests/unit/session-variant-store.test.ts b/packages/kilo-vscode/tests/unit/session-variant-store.test.ts index a2b678009c3..5294c21f8ad 100644 --- a/packages/kilo-vscode/tests/unit/session-variant-store.test.ts +++ b/packages/kilo-vscode/tests/unit/session-variant-store.test.ts @@ -3,6 +3,7 @@ import { cycleVariant, getAgentVariant, getVariant, + preserveVariant, sessionVariantKeys, sessionVariants, transferVariants, @@ -121,3 +122,24 @@ describe("cycleVariant", () => { expect(cycleVariant("low", [])).toBeUndefined() }) }) + +describe("preserveVariant", () => { + it("keeps an exact variant", () => { + expect(preserveVariant("high", ["low", "high"])).toBe("high") + expect(preserveVariant("thinking", ["instant", "thinking"])).toBe("thinking") + expect(preserveVariant("default", ["default", "thinking"])).toBe("default") + }) + + it("falls back to the nearest supported effort", () => { + expect(preserveVariant("max", ["high", "xhigh"])).toBe("xhigh") + expect(preserveVariant("high", ["low", "medium"])).toBe("medium") + expect(preserveVariant("max", ["none", "low"])).toBe("low") + }) + + it("does not cross binary or custom variant families", () => { + expect(preserveVariant("thinking", ["low", "high"])).toBeUndefined() + expect(preserveVariant("instant", ["low", "high"])).toBeUndefined() + expect(preserveVariant("turbo", ["low", "high"])).toBeUndefined() + expect(preserveVariant("high", ["instant", "thinking"])).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index a2d88dfbd5e..f265bc2d158 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -23,7 +23,7 @@ import { useServer } from "../src/context/server" import { useSession } from "../src/context/session" import { useProvider } from "../src/context/provider" import { useConfig } from "../src/context/config" -import { cycleVariant } from "../src/context/session-variant-store" +import { cycleVariant, preserveVariant } from "../src/context/session-variant-store" import { ModelSelectorBase } from "../src/components/shared/ModelSelector" import { ModeSwitcherBase } from "../src/components/shared/ModeSwitcher" import { SpeechToTextButton } from "../src/components/speech-to-text/SpeechToTextButton" @@ -268,7 +268,7 @@ export const NewWorktreeDialog: Component<{ return } const stored = variant() - if (!stored || !list.includes(stored)) setVariant(list[0]) + if (!stored || !list.includes(stored)) setVariant(preserveVariant(stored, list) ?? list[0]) }) createEffect(() => { @@ -871,7 +871,12 @@ export const NewWorktreeDialog: Component<{ { - if (pid && mid) setModel({ providerID: pid, modelID: mid }) + if (!pid || !mid) return + const current = effectiveVariant() + const next = { providerID: pid, modelID: mid } + const list = Object.keys(provider.findModel(next)?.variants ?? {}) + setModel(next) + setVariant(preserveVariant(current, list)) }} onPick={restorePrompt} onCancel={restorePrompt} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx index 619cf17bd13..a87865044f7 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx @@ -16,6 +16,7 @@ import { DEFAULT_SPEECH_TO_TEXT_MODEL } from "../../../../src/speech-to-text/mod import { hasSpeechToTextAccess, selectedSpeechToTextModel } from "../speech-to-text/availability" import { speechToTextModelOptions } from "../speech-to-text/model-selector" import { AUTOCOMPLETE_SELECTOR_MODELS, getAutocompleteSelection } from "./autocomplete-model-selector" +import { preserveVariant } from "../../context/session-variant-store" const ModelsTab: Component = () => { const { config, settings, updateConfig, updateSetting } = useConfig() @@ -64,9 +65,12 @@ const ModelsTab: Component = () => { return } const value = `${providerID}/${modelID}` + const list = Object.keys(provider.findModel({ providerID, modelID })?.variants ?? {}) + const next = preserveVariant(subagentVariant(), list) updateConfig({ subagent_model: value, ...(config().subagent_model === value ? {} : { subagent_variant: null }), + ...(next ? { subagent_variant_overrides: { ...config().subagent_variant_overrides, [value]: next } } : {}), }) } @@ -87,7 +91,17 @@ const ModelsTab: Component = () => { updateConfig({ agent: { [agentName]: { model: null } } }) return } - updateConfig({ agent: { [agentName]: { model: `${providerID}/${modelID}` } } }) + const current = config().agent?.[agentName]?.variant ?? undefined + const list = Object.keys(provider.findModel({ providerID, modelID })?.variants ?? {}) + const next = preserveVariant(current, list) + updateConfig({ + agent: { + [agentName]: { + model: `${providerID}/${modelID}`, + ...(current && !list.includes(current) ? { variant: next ?? null } : {}), + }, + }, + }) } } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour/WorkflowsTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour/WorkflowsTab.tsx index afcbba8a83b..f0f9a91851d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour/WorkflowsTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour/WorkflowsTab.tsx @@ -9,6 +9,7 @@ import { ModelSelectorBase } from "../../shared/ModelSelector" import { ThinkingSelectorBase } from "../../shared/ThinkingSelector" import { parseModelString } from "../../../../../src/shared/provider-model" import type { CommandConfig } from "../../../types/messages" +import { preserveVariant } from "../../../context/session-variant-store" const WorkflowsTab: Component = () => { const language = useLanguage() @@ -48,9 +49,10 @@ const WorkflowsTab: Component = () => { const selectModel = (name: string, providerID: string, modelID: string) => { const list = Object.keys(provider.findModel({ providerID, modelID })?.variants ?? {}) const current = variant(config().command?.[name] ?? {}, name) + const next = preserveVariant(current, list) update(name, { model: providerID && modelID ? `${providerID}/${modelID}` : null, - ...(current && !list.includes(current) ? { variant: null } : {}), + ...(current && !list.includes(current) ? { variant: next ?? null } : {}), }) } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/mode-model.ts b/packages/kilo-vscode/webview-ui/src/components/settings/mode-model.ts index 7abbac3f672..e39eb19e612 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/mode-model.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/mode-model.ts @@ -1,4 +1,5 @@ import type { AgentConfig } from "../../types/messages" +import { preserveVariant } from "../../context/session-variant-store" export function modelPatch( providerID: string, @@ -12,6 +13,6 @@ export function modelPatch( return { model: `${providerID}/${modelID}`, - ...(current && !variants.includes(current) ? { variant: null } : {}), + ...(current && !variants.includes(current) ? { variant: preserveVariant(current, variants) ?? null } : {}), } } diff --git a/packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts b/packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts index bbbb5ca5a8a..0eb363b0d29 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts @@ -1,5 +1,26 @@ import type { ModelSelection } from "../types/messages" +const effort = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] + +/** Keep the selected effort when possible, falling back to the nearest known effort. */ +export function preserveVariant(current: string | undefined, variants: string[]) { + if (!current || variants.length === 0) return undefined + if (variants.includes(current)) return current + + const rank = effort.indexOf(current) + if (rank === -1) return undefined + + return variants + .map((value, index) => ({ + value, + index, + rank: effort.indexOf(value), + distance: Math.abs(effort.indexOf(value) - rank), + })) + .filter((item) => effort.includes(item.value)) + .sort((a, b) => a.distance - b.distance || b.rank - a.rank || a.index - b.index)[0]?.value +} + export function legacyVariantKey(sel: ModelSelection) { return `${sel.providerID}/${sel.modelID}` } @@ -21,7 +42,7 @@ export function getVariant( const key = variantKey(sel, agent, session) const fallback = session ? store[variantKey(sel, agent)] : undefined const stored = store[key] ?? fallback ?? store[legacyVariantKey(sel)] - return stored && variants.includes(stored) ? stored : variants[0] + return preserveVariant(stored, variants) ?? variants[0] } export function getAgentVariant( diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index dcfd8e57e24..839b0dc3f50 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -74,7 +74,14 @@ import { errorIDs } from "./session-errors" import { PartStash } from "./part-stash" import { mergeParts, sameParts } from "./session-parts" import { state as todoState } from "./todo-revert" -import { getAgentVariant, getVariant, sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store" +import { + getAgentVariant, + getVariant, + preserveVariant, + sessionVariantKeys, + transferVariants, + variantKey, +} from "./session-variant-store" import { KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "../../../src/shared/provider-model" import { reviewMetadata, type ReviewMessageData } from "../../../src/shared/review-comments" import { visibleMessages as filterVisibleMessages } from "./session-queue" @@ -663,9 +670,22 @@ export const SessionProvider: ParentComponent = (props) => { }) } + function carryVariant(selection: ModelSelection, current: string | undefined, agent: string, sessionID?: string) { + const value = preserveVariant(current, Object.keys(provider.findModel(selection)?.variants ?? {})) + if (!value) return + const key = variantKey(selection, agent, sessionID) + setStore("variantSelections", key, value) + if (!sessionID) vscode.postMessage({ type: "persistVariant", key, value }) + } + function selectModel(providerID: string, modelID: string, sessionID?: string) { const sid = sessionID ?? currentSessionID() - applyModel(agentForScope(sid), { providerID, modelID }, sid) + const agent = agentForScope(sid) + const current = selected(sid) + const value = current ? currentVariant(sid) : undefined + const selection = { providerID, modelID } + applyModel(agent, selection, sid) + carryVariant(selection, value, agent, sid) if (sid) { hideErrors(sid) } @@ -3033,8 +3053,12 @@ export const SessionProvider: ParentComponent = (props) => { // agent may not yet be assigned (sendInitialMessage calls setSessionModel // before setSessionAgent), so the write would land on defaultAgent() and // corrupt the default mode's model for later sessions. + const agent = store.agentSelections[sessionID] ?? defaultAgent() + const current = selected(sessionID) + const value = current ? currentVariant(sessionID) : undefined const model = { providerID, modelID } setStore("sessionOverrides", sessionID, model) + carryVariant(model, value, agent, sessionID) }, setSessionAgent: (sessionID: string, name: string) => { setStore("agentSelections", sessionID, name) diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index 64d06dfcfc6..3abd5c312a3 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -834,11 +834,7 @@ export class RunFooter implements FooterApi { return } - const previous = this.currentModel() this.setCurrentModel(model) - if (!previous || previous.providerID !== model.providerID || previous.modelID !== model.modelID) { - this.setCurrentVariant(undefined) - } void Promise.resolve() .then(() => this.options.onModelSelect?.(model)) .then((result) => { diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index f459fdea8b7..2cc8ae67341 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -21,6 +21,8 @@ import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./run import { createRuntimeLifecycle } from "./runtime.lifecycle" import { trace } from "./trace" import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared" +// kilocode_change - preserve compatible variants when switching models +import { resolvePreservedVariant } from "@/kilocode/cli/cmd/run/variant" // kilocode_change import type { LocalReplayAnchor, LocalReplayRow, RunInput, RunPrompt, RunProvider, StreamCommit } from "./types" /** @internal Exported for testing */ @@ -294,8 +296,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep return } + // kilocode_change start - preserve the active effort across model switches + const previous = state.activeVariant state.model = model - state.activeVariant = undefined state.variants = variantsFor(state.providers, model) const switching = resolveSavedVariant(model).then((saved) => { const current = state.model @@ -303,8 +306,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep return } - state.activeVariant = resolveVariant(ctx.variant, undefined, saved, state.variants) + // kilocode_change - prefer the active effort over a model-specific saved preference + state.activeVariant = + resolvePreservedVariant(ctx.variant, previous, state.variants) ?? + resolveVariant(ctx.variant, undefined, saved, state.variants) }) + // kilocode_change end state.switching = switching await switching if (state.switching === switching) { @@ -440,10 +447,14 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep state.variants = variantsFor(state.providers, state.model) state.limits = info.limits - const next = resolveVariant(ctx.variant, session.variant, savedVariant, state.variants) + // kilocode_change start - preserve the active effort when the model catalog arrives asynchronously + const next = + resolvePreservedVariant(ctx.variant, state.activeVariant, state.variants) ?? + resolveVariant(ctx.variant, session.variant, savedVariant, state.variants) if (next !== state.activeVariant) { state.activeVariant = next } + // kilocode_change end if (footer.isClosed) { return diff --git a/packages/opencode/src/kilocode/cli/cmd/run/variant.ts b/packages/opencode/src/kilocode/cli/cmd/run/variant.ts new file mode 100644 index 00000000000..ed2d85c1a8b --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/run/variant.ts @@ -0,0 +1,29 @@ +const effort = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] + +/** Keep an explicit CLI variant verbatim; only infer a fallback for automatic selections. */ +export function resolvePreservedVariant( + input: string | undefined, + current: string | undefined, + variants: string[], +): string | undefined { + return input ?? preserveVariant(current, variants) +} + +/** Keep the selected effort when possible, falling back to the nearest known effort. */ +export function preserveVariant(current: string | undefined, variants: string[]): string | undefined { + if (!current || variants.length === 0) return undefined + if (variants.includes(current)) return current + + const rank = effort.indexOf(current) + if (rank === -1) return undefined + + return variants + .map((value, index) => ({ + value, + index, + rank: effort.indexOf(value), + distance: Math.abs(effort.indexOf(value) - rank), + })) + .filter((item) => effort.includes(item.value)) + .sort((a, b) => a.distance - b.distance || b.rank - a.rank || a.index - b.index)[0]?.value +} diff --git a/packages/opencode/test/kilocode/cli/run/variant.test.ts b/packages/opencode/test/kilocode/cli/run/variant.test.ts new file mode 100644 index 00000000000..608e4452db2 --- /dev/null +++ b/packages/opencode/test/kilocode/cli/run/variant.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test" +import { preserveVariant, resolvePreservedVariant } from "@/kilocode/cli/cmd/run/variant" + +describe("Kilo CLI variant preservation", () => { + test("keeps exact variants across supported families", () => { + expect(preserveVariant("high", ["low", "high"])).toBe("high") + expect(preserveVariant("thinking", ["instant", "thinking"])).toBe("thinking") + expect(preserveVariant("default", ["default", "thinking"])).toBe("default") + }) + + test("keeps an explicit CLI variant verbatim", () => { + expect(resolvePreservedVariant("max", "high", ["low", "medium", "high"])).toBe("max") + expect(resolvePreservedVariant("thinking", "high", ["low", "medium", "high"])).toBe("thinking") + }) + + test("falls back to the nearest supported reasoning effort", () => { + expect(preserveVariant("max", ["high", "xhigh"])).toBe("xhigh") + expect(preserveVariant("high", ["low", "medium"])).toBe("medium") + expect(preserveVariant("max", ["none", "low"])).toBe("low") + }) + + test("does not cross binary or custom variant families", () => { + expect(preserveVariant("thinking", ["low", "high"])).toBeUndefined() + expect(preserveVariant("instant", ["low", "high"])).toBeUndefined() + expect(preserveVariant("turbo", ["low", "high"])).toBeUndefined() + expect(preserveVariant("high", ["instant", "thinking"])).toBeUndefined() + }) +})