Merge pull request #12944 from Kilo-Org/persist-model-variant-selection

fix(vscode): preserve model variants on switch
This commit is contained in:
Marius
2026-08-06 16:29:23 +02:00
committed by GitHub
13 changed files with 182 additions and 17 deletions
+5
View File
@@ -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.
@@ -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,
})
@@ -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()
})
})
@@ -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<{
<ModelSelectorBase
value={model()}
onSelect={(pid, mid) => {
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}
@@ -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 } : {}),
},
},
})
}
}
@@ -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 } : {}),
})
}
@@ -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 } : {}),
}
}
@@ -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(
@@ -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)
@@ -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) => {
+14 -3
View File
@@ -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
@@ -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
}
@@ -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()
})
})