Merge pull request #14180 from Kilo-Org/explicit-model-selection-lost

fix(vscode): default model not persistent after explicit user choice
This commit is contained in:
Andrea Giammarchi
2026-09-16 13:41:32 +02:00
committed by GitHub
29 changed files with 1947 additions and 287 deletions
@@ -0,0 +1,8 @@
---
"kilo-code": patch
---
Keep model and reasoning choices when switching modes, and remember explicit new-session choices as the default without changing open sessions.
- Keep an untouched draft on Default reasoning when selecting a different effort in another draft.
- Keep the model and reasoning effort together when a mode-only command starts a new session.
@@ -96,12 +96,12 @@ export function buildInitialMessages(
worktreeId: entry.worktreeId,
providerID: pid,
modelID: mid,
agent,
// A per-allocation effort pick wins even when preparing an empty session.
variant: model?.variant ?? variant,
}
if (prompt) {
msg.text = prompt
msg.agent = agent
// A per-allocation effort pick wins over the dialog-level variant.
msg.variant = model?.variant ?? variant
msg.files = files
}
return msg
@@ -1,12 +1,13 @@
/**
* Per-mode model selection persistence via the CLI's model.json.
* Model selection persistence in the CLI state directory.
*
* Reads/writes ~/.local/state/kilo/model.json (same file the CLI TUI uses)
* so per-mode model choices are shared between CLI and extension.
* Per-mode choices use the shared model.json. The explicit preferred combo uses
* vscode-model.json so CLI/TUI writers cannot discard extension-only state.
*/
import * as fs from "fs"
import * as path from "path"
import { randomUUID } from "crypto"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { validateModelSelections } from "../provider-actions"
@@ -17,21 +18,20 @@ let queue: Promise<void> = Promise.resolve()
async function resolve(client: KiloClient | null): Promise<string | undefined> {
if (cached) return cached
if (!client) return undefined
try {
const resp = await client?.path.get()
if (!resp?.data?.state) return undefined
cached = path.join(resp.data.state, "model.json")
const resp = await client.path.get()
if (typeof resp?.data?.state !== "string" || !resp.data.state) return undefined
cached = resp.data.state
return cached
} catch {
return undefined
}
}
async function read(client: KiloClient | null): Promise<Record<string, unknown>> {
const p = await resolve(client)
if (!p) return {}
async function read(file: string): Promise<Record<string, unknown>> {
try {
const raw = await fs.promises.readFile(p, "utf-8")
const raw = await fs.promises.readFile(file, "utf-8")
const parsed = JSON.parse(raw)
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
@@ -41,15 +41,51 @@ async function read(client: KiloClient | null): Promise<Record<string, unknown>>
}
}
function write(client: KiloClient | null, key: string, value: unknown): Promise<void> {
function selection(raw: unknown) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined
const value = raw as Record<string, unknown>
if (
typeof value.providerID !== "string" ||
!value.providerID ||
typeof value.modelID !== "string" ||
!value.modelID ||
(value.variant !== undefined && typeof value.variant !== "string")
) {
return undefined
}
return {
providerID: value.providerID,
modelID: value.modelID,
...(typeof value.variant === "string" ? { variant: value.variant } : {}),
}
}
function write(
client: KiloClient | null,
update: (data: Record<string, unknown>) => void,
preferred?: ReturnType<typeof selection>,
): Promise<void> {
const op = queue.then(async () => {
const p = await resolve(client)
if (!p) return
const existing = await read(client)
existing[key] = value
await fs.promises.writeFile(p, JSON.stringify(existing, null, 2))
const dir = await resolve(client)
if (!dir) return
const file = path.join(dir, "model.json")
const existing = await read(file)
update(existing)
await fs.promises.writeFile(file, JSON.stringify(existing, null, 2))
const target = path.join(dir, "vscode-model.json")
const temp = `${target}.${randomUUID()}.tmp`
try {
await fs.promises.writeFile(temp, JSON.stringify({ preferred }, null, 2))
await fs.promises.rename(temp, target)
} finally {
await fs.promises.rm(temp, { force: true }).catch((err) => {
console.error("[Kilo New] Failed to remove temporary model preferences:", err)
})
}
})
queue = op.catch((err) => {
console.error("[Kilo New] Failed to persist model selections:", err)
})
queue = op.catch(() => {})
return op
}
@@ -63,25 +99,39 @@ export async function handleMessage(
post: PostMessage,
): Promise<boolean> {
if (type === "persistModelSelection") {
const data = await read(client)
const model = validateModelSelections(data.model)
model[message.agent as string] = {
providerID: message.providerID as string,
modelID: message.modelID as string,
}
await write(client, "model", model)
const preferred = selection(message)
const agent = message.agent
if (!preferred || typeof agent !== "string" || !agent) return true
await write(
client,
(data) => {
const model = validateModelSelections(data.model)
model[agent] = { providerID: preferred.providerID, modelID: preferred.modelID }
data.model = model
},
{ ...preferred, variant: preferred.variant ?? "" },
)
return true
}
if (type === "requestModelSelections") {
const data = await read(client)
await queue
const dir = await resolve(client)
if (!dir) return true
const [data, prefs] = await Promise.all([
read(path.join(dir, "model.json")),
read(path.join(dir, "vscode-model.json")),
])
const selections = validateModelSelections(data.model)
post({ type: "modelSelectionsLoaded", selections })
const preferred = selection(prefs.preferred)
post({ type: "modelSelectionsLoaded", selections, ...(preferred ? { preferred } : {}) })
return true
}
return false
}
export async function reset(client: KiloClient | null, post: PostMessage): Promise<void> {
await write(client, "model", {})
await write(client, (data) => {
data.model = {}
})
post({ type: "modelSelectionsLoaded", selections: {} })
}
@@ -0,0 +1,169 @@
import assert from "node:assert/strict"
import { jest, spyOn } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createPreferenceLoader } from "../../webview-ui/src/context/session-preference-loader"
function setup(online = true, loaded = false) {
return createRoot((dispose) => {
const [ready, hydrate] = createSignal(loaded)
const [connected, connect] = createSignal(online)
const sent: number[] = []
const retry = createPreferenceLoader({ ready, connected, request: () => sent.push(Date.now()) })
return { dispose, ready, hydrate, connect, sent, retry }
})
}
jest.useFakeTimers()
const warning = spyOn(console, "warn").mockImplementation(() => undefined)
const cases: Record<string, () => void> = {
exhaustion() {
const state = setup()
const start = Date.now()
assert.deepEqual(state.sent, [start])
jest.advanceTimersByTime(2999)
assert.equal(state.sent.length, 1)
jest.advanceTimersByTime(6001)
assert.deepEqual(state.sent, [start, start + 3000, start + 6000, start + 9000])
assert.equal(warning.mock.calls.length, 0, "the final request gets a response window")
jest.advanceTimersByTime(3000)
assert.equal(warning.mock.calls.length, 1)
assert.match(String(warning.mock.calls.at(0)?.at(0)), /\[Kilo New\].*preferences.*4 attempts/)
assert.equal(state.ready(), false, "exhaustion must not synthesize successful empty preferences")
assert.equal(jest.getTimerCount(), 0)
jest.advanceTimersByTime(60000)
assert.equal(state.sent.length, 4)
assert.equal(warning.mock.calls.length, 1)
state.hydrate(true)
assert.equal(state.ready(), true, "genuine late hydration remains authoritative")
state.retry()
assert.equal(state.sent.length, 4)
state.dispose()
},
offline() {
const state = setup(false)
assert.equal(state.sent.length, 1, "request the host's cached disk path even while offline")
assert.equal(jest.getTimerCount(), 0)
jest.advanceTimersByTime(60000)
assert.equal(state.sent.length, 1)
assert.equal(warning.mock.calls.length, 0)
state.retry()
assert.equal(state.sent.length, 2, "extensionDataReady may also arrive before connected state")
assert.equal(jest.getTimerCount(), 0)
state.connect(true)
assert.equal(state.sent.length, 3)
assert.equal(jest.getTimerCount(), 1)
jest.advanceTimersByTime(3000)
assert.equal(state.sent.length, 4)
state.dispose()
},
reconnect() {
const state = setup()
jest.advanceTimersByTime(3000)
assert.equal(state.sent.length, 2)
state.connect(false)
assert.equal(jest.getTimerCount(), 0)
jest.advanceTimersByTime(60000)
assert.equal(state.sent.length, 2)
assert.equal(warning.mock.calls.length, 0)
state.connect(true)
assert.equal(state.sent.length, 3)
jest.advanceTimersByTime(12000)
assert.equal(state.sent.length, 6)
assert.equal(warning.mock.calls.length, 1)
state.connect(true)
assert.equal(jest.getTimerCount(), 0, "unchanged connection does not restart an exhausted cycle")
state.connect(false)
state.connect(true)
assert.equal(state.sent.length, 7, "reconnect restarts even after exhaustion")
jest.advanceTimersByTime(12000)
assert.equal(state.sent.length, 10)
assert.equal(warning.mock.calls.length, 2, "warn once per exhausted connection cycle")
assert.equal(state.ready(), false)
state.dispose()
},
retry() {
const state = setup()
jest.advanceTimersByTime(1000)
state.retry()
assert.equal(state.sent.length, 2)
assert.equal(jest.getTimerCount(), 1, "extensionDataReady replaces, not duplicates, the timer")
jest.advanceTimersByTime(2000)
assert.equal(state.sent.length, 2, "the original timer was cancelled")
jest.advanceTimersByTime(1000)
assert.equal(state.sent.length, 3)
jest.advanceTimersByTime(9000)
assert.equal(state.sent.length, 5)
assert.equal(warning.mock.calls.length, 1)
state.retry()
assert.equal(state.sent.length, 6, "extensionDataReady can recover an exhausted cycle")
jest.advanceTimersByTime(12000)
assert.equal(state.sent.length, 9)
assert.equal(warning.mock.calls.length, 2)
assert.equal(jest.getTimerCount(), 0)
state.dispose()
},
ready() {
const state = setup()
jest.advanceTimersByTime(9000)
state.hydrate(true)
assert.equal(jest.getTimerCount(), 0, "readiness cancels the final response window")
state.connect(false)
state.connect(true)
state.retry()
jest.advanceTimersByTime(60000)
assert.equal(state.sent.length, 4)
assert.equal(warning.mock.calls.length, 0)
state.dispose()
},
cleanup() {
const state = setup()
assert.equal(jest.getTimerCount(), 1)
state.dispose()
assert.equal(jest.getTimerCount(), 0)
state.retry()
state.connect(false)
state.connect(true)
jest.advanceTimersByTime(60000)
assert.equal(state.sent.length, 1, "disposed owners cannot restart requests")
assert.equal(warning.mock.calls.length, 0)
assert.equal(state.ready(), false)
},
loaded() {
const state = setup(false, true)
state.connect(true)
state.retry()
assert.equal(state.sent.length, 0)
assert.equal(jest.getTimerCount(), 0)
state.dispose()
},
synchronous() {
createRoot((dispose) => {
const [ready, hydrate] = createSignal(false)
let sent = 0
const retry = createPreferenceLoader({
ready,
connected: () => true,
request: () => {
sent++
hydrate(true)
},
})
assert.equal(ready(), true)
assert.equal(jest.getTimerCount(), 0, "a cached synchronous response does not leave a timer")
retry()
assert.equal(sent, 1)
dispose()
})
},
}
try {
const name = process.argv.at(2)
assert.ok(name && cases[name], `Unknown preference loader case: ${name}`)
cases[name]()
assert.equal(jest.getTimerCount(), 0, "each case disposes all retry timers")
} finally {
warning.mockRestore()
jest.useRealTimers()
}
@@ -58,6 +58,8 @@ const { LanguageContext } = await import("../../webview-ui/src/context/language"
const { NotificationsProvider } = await import("../../webview-ui/src/context/notifications")
const { ProviderProvider } = await import("../../webview-ui/src/context/provider")
const { SessionProvider, useSession, useSessionVisibility } = await import("../../webview-ui/src/context/session")
const { LocalTabsProvider, useLocalTabs } = await import("../../webview-ui/src/context/local-tabs")
const { createProjectRegistry } = await import("../../webview-ui/agent-manager/project/registry")
const { initialMessage } = await import("../../webview-ui/agent-manager/initial-message")
const { useBaseUpdate } = await import("../../webview-ui/agent-manager/update-from-base")
const { post } = await import("../../webview-ui/src/utils/webview-message")
@@ -114,6 +116,12 @@ const [active, setActive] = createSignal("task-child")
const [review, setReview] = createSignal(false)
const [sharing, setSharing] = createSignal(false)
const peer = { value: undefined as ReturnType<typeof useSession> | undefined }
const [tabbed, setTabbed] = createSignal(false)
const tabs = { value: undefined as ReturnType<typeof useLocalTabs> | undefined }
const Tabs = () => {
tabs.value = useLocalTabs()
return null
}
const Peer = () => {
peer.value = useSession()
return null
@@ -143,6 +151,11 @@ const Probe = () => {
} as Parameters<typeof renderTab>[1]
return (
<DragDropProvider>
<Show when={tabbed()}>
<LocalTabsProvider>
<Tabs />
</LocalTabsProvider>
</Show>
<Show when={sharing()}>
<SessionProvider>
<Peer />
@@ -350,7 +363,13 @@ try {
const writes = () => sent.filter((item) => item.type === "persistModelSelection" || item.type === "persistRecents")
const requests = () =>
sent.filter((item) => ["sendMessage", "sendCommand", "importAndSend", "compact"].includes(item.type))
const catalog = async (organizationId: string | null, ids: string[], model?: string, ready = true) => {
const catalog = async (
organizationId: string | null,
ids: string[],
model?: string,
ready = true,
variants = ["low", "high"],
) => {
await emit({
type: "providersLoaded",
organizationId,
@@ -359,7 +378,12 @@ try {
kilo: {
id: "kilo",
name: "Kilo",
models: Object.fromEntries(ids.map((id) => [id, { id, name: id, variants: { low: {}, high: {} } }])),
models: Object.fromEntries(
ids.map((id) => [
id,
{ id, name: id, variants: Object.fromEntries(variants.map((variant) => [variant, {}])) },
]),
),
},
openai: { id: "openai", name: "OpenAI", models: { external: { id: "external", name: "External" } } },
},
@@ -656,13 +680,44 @@ try {
choice(value.selected(), auto)
setSettings({ agent: { code: { model: "kilo/z-first" } } })
await settle()
choice(value.modelForAgent("code"), first)
choice(value.modelForAgent("code"), auto)
choice(value.selected(), auto)
setSettings({})
await emit({ type: "modelSelectionsLoaded", selections: {} })
choice(value.selected(), recommended)
assert.deepEqual(writes(), remembered)
// Mode changes preserve both explicit and inherited session choices, including Default.
await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID)
setSettings({ agent: { ask: { model: "kilo/z-first", variant: "low" } } })
for (const effort of ["high", ""]) {
for (const scope of [`selection-${effort}`, `pending:${effort}`, `sidebar-pending:${effort}`]) {
value.setSessionAgent(scope, "code")
value.setSessionModel(scope, personal.providerID, personal.modelID)
value.selectVariant(effort, scope)
const before = sent.length
for (const agent of ["ask", "code", "ask"]) {
value.selectAgent(agent, scope)
await settle()
assert.deepEqual(value.submission(scope), { model: personal, variant: effort, agent })
}
assert.equal(sent.length, before, "Switching mode must not persist a different preference")
await emit({ type: "providersLoading" })
value.selectAgent("code", scope)
await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID)
assert.deepEqual(value.submission(scope), { model: personal, variant: effort, agent: "code" })
}
}
setSettings({
agent: { code: { model: "kilo/personal", variant: "high" }, ask: { model: "kilo/z-first", variant: "low" } },
})
value.setSessionAgent("inherited-mode", "code")
await emit({ type: "messagesLoaded", sessionID: "inherited-mode", messages: [] })
const inherited = value.submission("inherited-mode")
value.selectAgent("ask", "inherited-mode")
assert.deepEqual(value.submission("inherited-mode"), { ...inherited, agent: "ask" })
setSettings({})
const snapshot = (scope?: string) =>
JSON.stringify({
session: value.currentSessionID(),
@@ -737,12 +792,14 @@ try {
for (const configured of [false, true]) {
const scope = `ses_command-${configured ? "configured" : "preferred"}`
setSettings(configured ? { agent: { ask: { model: "kilo/z-first", variant: "high" } } } : {})
setSettings(configured ? { agent: { ask: { model: "kilo/z-first", variant: "low" } } } : {})
await emit({ type: "modelSelectionsLoaded", selections: { code: first, ask: recommended } })
value.setCurrentSessionID(scope)
value.setSessionAgent(scope, "code")
value.setSessionModel(scope, personal.providerID, personal.modelID)
value.setSessionVariant(scope, personal.providerID, personal.modelID, "high")
await settle()
assert.deepEqual(value.submission(scope), { model: personal, variant: "high", agent: "code" })
assert.equal(
value.sendCommand(
"review-test",
@@ -761,10 +818,10 @@ try {
assert(request?.type === "sendCommand")
assert.equal(request.sessionID, scope)
assert.equal(request.agent, "ask")
assert.equal(request.modelID, configured ? first.modelID : recommended.modelID)
assert.equal(request.variant, configured ? "high" : "low")
assert.equal(request.modelID, personal.modelID)
assert.equal(request.variant, "high")
assert.equal(value.selectedAgent(scope), "ask")
choice(value.selected(scope), configured ? first : recommended)
choice(value.selected(scope), personal)
}
setSettings({})
await catalog(null, [auto.modelID, personal.modelID, first.modelID, recommended.modelID])
@@ -774,9 +831,15 @@ try {
await settle()
value.selectAgent("code")
await settle()
setSettings({ agent: { ask: { model: "kilo/a-recommended", variant: "high" } } })
setSettings({
agent: { code: { model: "kilo/z-first", variant: "high" }, ask: { model: "kilo/a-recommended", variant: "low" } },
})
await emit({ type: "variantsLoaded", variants: { "agent/code/kilo/z-first": "high" } })
value.setCurrentSessionID("ses_command-cached")
value.setSessionAgent("ses_command-cached", "code")
await emit({ type: "messagesLoaded", sessionID: "ses_command-cached", messages: [] })
await settle()
assert.deepEqual(value.submission("ses_command-cached"), { model: first, variant: "high", agent: "code" })
assert.equal(
value.sendCommand(
"review-test",
@@ -793,15 +856,36 @@ try {
)
const configured = requests().at(-1)
assert(configured?.type === "sendCommand")
assert.equal(configured.modelID, recommended.modelID)
assert.equal(configured.modelID, first.modelID)
assert.equal(configured.variant, "high")
choice(value.selected(), recommended)
assert.deepEqual(value.submission("ses_command-cached"), { model: first, variant: "high", agent: "ask" })
assert.equal(
value.sendCommand(
"review-test",
"default effort",
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{ variant: "" },
),
true,
)
const commandDefault = sent.findLast((item) => item.type === "sendCommand")
assert(commandDefault)
assert.equal(commandDefault.variant, "")
assert.equal(value.currentVariant("ses_command-cached"), undefined)
setSettings({})
await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID)
await emit({ type: "modelSelectionsLoaded", selections: { code: first, ask: recommended } })
value.setCurrentSessionID(undefined)
value.selectAgent("ask")
choice(value.selected(), first)
assert.equal(value.variantForAgent("ask", first), "high")
value.setCurrentSessionID("selection")
assert.equal(
value.sendCommand(
@@ -822,12 +906,12 @@ try {
assert(pending.draftID)
assert.equal(pending.sessionID, undefined)
assert.equal(pending.agent, "ask")
assert.equal(pending.modelID, recommended.modelID)
assert.equal(pending.modelID, first.modelID)
assert.equal(pending.variant, "high")
assert.equal(value.selectedAgent(pending.draftID), "ask")
choice(value.selected(pending.draftID), recommended)
choice(value.selected(pending.draftID), first)
assert.equal(value.currentVariant(pending.draftID), "high")
assert.equal(value.variantForAgent("ask", recommended), "low")
assert.equal(value.variantForAgent("ask", first), "high")
const persisted = sent.length
assert.equal(
value.sendCommand(
@@ -855,12 +939,14 @@ try {
choice(value.selected(accepted.draftID), personal)
assert.equal(value.selectedAgent(accepted.draftID), "ask")
assert.equal(value.currentVariant(accepted.draftID), "high")
choice(value.modelForAgent("ask"), recommended)
choice(value.modelForAgent("ask"), first)
assert.equal(
sent.slice(persisted).some((message) => message.type === "persistModelSelection"),
false,
)
await emit({ type: "sessionCreated", session: info("ses_command-promoted"), draftID: accepted.draftID })
assert.equal(Object.hasOwn(value.allMessages(), accepted.draftID), false)
assert(value.allMessages()["ses_command-promoted"]?.some((message) => message.id === accepted.messageID))
choice(value.selected("ses_command-promoted"), personal)
assert.equal(value.selectedAgent("ses_command-promoted"), "ask")
assert.equal(value.currentVariant("ses_command-promoted"), "high")
@@ -900,7 +986,7 @@ try {
value.clearCurrentSession()
value.selectAgent("ask")
assert.equal(value.selectedAgent(), "ask")
choice(value.selected(), recommended)
choice(value.selected(), first)
const usage = JSON.stringify(value.modelUsageHistory())
const recent = JSON.stringify(value.recentModels())
const start = sent.length
@@ -917,10 +1003,16 @@ try {
sent.slice(start).map((message) => message.type),
["sendCommand"],
)
assert.deepEqual(unwrap(value.allMessages()[command.draftID]), [])
await emit({ type: "sessionCreated", session: info("ses_goal-draft"), draftID: command.draftID })
assert.equal(
Object.hasOwn(value.allMessages(), command.draftID),
false,
"Promotion must remove empty goal draft caches",
)
assert.equal(value.currentSessionID(), "ses_goal-draft")
assert.equal(value.selectedAgent(), "ask")
choice(value.selected(), recommended)
choice(value.selected(), first)
await emit({ type: "sessionCommandCompleted", messageID: command.messageID })
assert.equal(value.submitting(), false)
assert.equal(JSON.stringify(value.modelUsageHistory()), usage)
@@ -2011,6 +2103,337 @@ try {
mode: "focus",
},
)
// An explicit next-session combo becomes the shared default, not a change to open sessions.
await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID)
await emit({ type: "modelSelectionsLoaded", selections: {} })
setSettings({
agent: { code: { model: "kilo/z-first", variant: "low" }, ask: { model: "kilo/a-recommended", variant: "low" } },
})
value.setCurrentSessionID("preference-active")
value.setSessionAgent("preference-active", "ask")
await emit({ type: "messagesLoaded", sessionID: "preference-active", messages: [] })
const combo = value.submission("preference-active")
for (const scope of ["pending:preferred", "sidebar-pending:preferred"]) {
value.setSessionAgent(scope, "code")
value.selectModel(personal.providerID, personal.modelID, scope)
value.selectVariant("high", scope)
assert.deepEqual(value.preferredSelection(), { ...personal, variant: "high" })
assert.deepEqual(value.submission("preference-active"), combo)
const saved = sent.findLast((item) => item.type === "persistModelSelection")
assert.deepEqual(saved, { type: "persistModelSelection", agent: "code", ...personal, variant: "high" })
for (const agent of ["code", "ask"]) {
choice(value.modelForAgent(agent), personal)
assert.equal(value.variantForAgent(agent, personal), "high")
}
value.selectAgent("ask", scope)
assert.deepEqual(value.submission(scope), { model: personal, variant: "high", agent: "ask" })
value.selectVariant(undefined, scope)
assert.deepEqual(value.preferredSelection(), { ...personal, variant: "" })
assert.equal(value.variantForAgent("code", personal), undefined)
await emit({ type: "providersLoading" })
value.selectAgent("code", scope)
await catalog("org-b", [first.modelID], first.modelID)
choice(value.selected(scope), first)
await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID)
assert.deepEqual(value.submission(scope), { model: personal, variant: "", agent: "code" })
assert.deepEqual(value.submission("preference-active"), combo)
}
value.rememberSelection("ask", first, "high")
assert.deepEqual(value.submission("preference-active"), combo)
assert.deepEqual(value.submission("pending:preferred"), { model: personal, variant: "", agent: "code" })
choice(value.modelForAgent("code"), first)
assert.equal(value.variantForAgent("code", first), "high")
value.setSessionModel("comparison-only", personal.providerID, personal.modelID)
value.setSessionVariant("comparison-only", personal.providerID, personal.modelID, "low")
assert.deepEqual(value.preferredSelection(), { ...first, variant: "high" })
// Restore the persisted combo through the real host-message boundary.
await emit({ type: "modelSelectionsLoaded", selections: { ask: first }, preferred: { ...first, variant: "high" } })
value.setCurrentSessionID(undefined)
value.selectAgent("code")
choice(value.selected(), first)
assert.equal(value.currentVariant(), "high")
value.selectModel(personal.providerID, personal.modelID)
choice(value.selected(), personal)
assert.equal(value.currentVariant(), "high")
assert.deepEqual(value.preferredSelection(), { ...personal, variant: "high" })
assert.deepEqual(value.submission("preference-active"), combo)
// Retention must not assign defaults to unopened or still-loading historical sessions.
await emit({
type: "sessionsLoaded",
sessions: [...unwrap(value.sessions()), info("unopened-preference"), info("loading-preference")],
})
value.setCurrentSessionID("loading-preference")
value.rememberSelection("code", first, "low")
for (const id of ["unopened-preference", "loading-preference"]) {
await emit({
type: "messagesLoaded",
sessionID: id,
messages: [
{
id: `${id}-message`,
sessionID: id,
role: "user",
agent: "ask",
model: { ...personal, variant: "high" },
createdAt: info(id).createdAt,
},
],
})
assert.deepEqual(value.submission(id), { model: personal, variant: "high", agent: "ask" })
}
// Pin raw inherited preferences, never a temporary catalog fallback or mapped effort.
value.rememberSelection("code", personal, "high")
for (const missing of ["model", "effort"]) {
const id = `pending:inherited-${missing}`
value.setSessionAgent(id, "code")
if (missing === "model") await catalog("org-b", [first.modelID], first.modelID)
if (missing === "effort") await catalog("org-a", [personal.modelID], personal.modelID, true, ["low"])
value.selectAgent("ask", id)
await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID)
assert.deepEqual(value.submission(id), { model: personal, variant: "high", agent: "ask" })
}
// A delayed startup response cannot erase a choice made while preferences load.
setSharing(false)
await settle()
setSharing(true)
await settle()
const fresh = peer.value
assert(fresh)
assert.equal(fresh.preferencesReady(), false)
const loadingPreferences = sent.filter((item) => item.type === "requestModelSelections").length
await emit({ type: "extensionDataReady" })
assert.equal(sent.filter((item) => item.type === "requestModelSelections").length, loadingPreferences + 1)
fresh.selectModel(personal.providerID, personal.modelID)
fresh.selectVariant("high")
await emit({ type: "modelSelectionsLoaded", selections: { code: first }, preferred: { ...first, variant: "low" } })
await emit({ type: "variantsLoaded", variants: { "agent/code/kilo/personal": "low" } })
choice(fresh.selected(), personal)
assert.equal(fresh.currentVariant(), "high")
assert.deepEqual(fresh.preferredSelection(), { ...personal, variant: "high" })
assert.equal(fresh.preferencesReady(), true)
// Unset effort defers to the new model; only a real choice can override its preference.
const outgoing = { providerID: "kilo", modelID: "unset-effort" }
await catalog("org-a", [outgoing.modelID, first.modelID], first.modelID)
for (const target of ["remembered", "configured"]) {
for (const effort of [undefined, "", "low"]) {
for (const scope of [undefined, "pending:carry", "session-carry"]) {
setSharing(false)
await settle()
setSharing(true)
await settle()
const instance = peer.value
assert(instance)
instance.selectAgent("code")
setSettings(target === "configured" ? { agent: { code: { model: "kilo/z-first", variant: "high" } } } : {})
await emit({ type: "modelSelectionsLoaded", selections: { code: outgoing } })
await emit({
type: "variantsLoaded",
variants: target === "remembered" ? { "agent/code/kilo/z-first": "high" } : {},
})
if (scope) instance.setSessionAgent(scope, "code")
if (effort !== undefined) instance.selectVariant(effort, scope)
choice(instance.selected(scope), outgoing)
instance.selectModel(first.providerID, first.modelID, scope)
const expected = effort ?? "high"
assert.deepEqual(
instance.submission(scope),
{ model: first, variant: expected, agent: "code" },
`${target}/${effort}/${scope}`,
)
if (!scope || scope.startsWith("pending:")) {
assert.deepEqual(instance.preferredSelection(), { ...first, variant: expected })
}
}
}
}
// Freezing another open draft preserves its displayed Default, without coercing model-switch carry.
{
setSharing(false)
await settle()
setSharing(true)
await settle()
const instance = peer.value
assert(instance)
setSettings({})
await emit({ type: "modelSelectionsLoaded", selections: { code: first } })
await emit({ type: "variantsLoaded", variants: {} })
const untrack = instance.trackScopes(() => ["pending:default-one", "pending:default-two"])
assert.equal(instance.currentVariant("pending:default-one"), undefined)
instance.selectVariant("high", "pending:default-two")
assert.equal(instance.currentVariant("pending:default-one"), undefined)
assert.equal(instance.submission("pending:default-one").variant, "")
assert.equal(instance.currentVariant("pending:default-two"), "high")
untrack()
}
// Generated command drafts are known-new before mode overrides resolve their effort.
{
setSharing(false)
await settle()
setSharing(true)
await settle()
const instance = peer.value
assert(instance)
setSettings({
agent: { code: { model: "kilo/z-first", variant: "high" }, ask: { model: "kilo/unset-effort", variant: "low" } },
})
await emit({ type: "modelSelectionsLoaded", selections: {} })
await emit({ type: "variantsLoaded", variants: {} })
instance.selectAgent("code")
assert.equal(instance.preferredSelection(), undefined)
assert.equal(
instance.sendCommand(
"review-test",
"configured draft",
undefined,
undefined,
undefined,
undefined,
undefined,
null,
{ agent: "ask" },
),
true,
)
const request = sent.findLast((item) => item.type === "sendCommand")
assert(request)
assert.equal(request.modelID, first.modelID)
assert.equal(request.variant, "high")
assert.equal(request.agent, "ask")
choice(instance.selected(request.draftID), first)
}
// Real sidebar tab inventories include untouched background drafts and reclaim closed drafts.
setSharing(false)
await catalog("org-a", [personal.modelID, first.modelID, outgoing.modelID], first.modelID)
value.clearCurrentSession()
value.rememberSelection("code", first, "high")
setTabbed(true)
await settle()
const sidebar = tabs.value
assert(sidebar)
const untouched = sidebar.ids().at(0)
assert(untouched)
const edited = sidebar.add()
value.selectModel(personal.providerID, personal.modelID, edited)
value.selectVariant("low", edited)
choice(value.selected(untouched), first)
assert.equal(value.currentVariant(untouched), "high")
sidebar.close(untouched)
sidebar.close(edited)
await settle()
for (const model of [first, personal, first]) {
value.rememberSelection("code", model, "low")
choice(value.selected(untouched), model)
choice(value.selected(edited), model)
assert.equal(value.currentVariant(edited), "low")
}
// Closing a sending draft retains its combo through promotion, or drops it after failure.
for (const accepted of [false, true]) {
const id = sidebar.add()
value.selectModel(first.providerID, first.modelID, id)
value.selectVariant("high", id)
assert.equal(value.sendMessage("in-flight preferences", first.providerID, first.modelID, undefined, id), true)
const request = sent.findLast((item) => item.type === "sendMessage")
assert(request)
sidebar.close(id)
await settle()
value.rememberSelection("code", personal, "low")
assert.equal(value.isSubmitting(id), true)
choice(value.selected(id), first)
assert.equal(value.currentVariant(id), "high")
if (accepted) {
await emit({ type: "sessionCreated", session: info("promoted-closed-draft"), draftID: id })
choice(value.selected("promoted-closed-draft"), first)
assert.equal(value.currentVariant("promoted-closed-draft"), "high")
await emit({ type: "sessionCommandCompleted", messageID: request.messageID })
}
if (!accepted) await emit({ ...request, type: "sendMessageFailed", error: "Test rejection" })
choice(value.selected(id), personal)
assert.equal(value.currentVariant(id), "low")
}
setTabbed(false)
await settle()
// Agent Manager retains all projects, not historical cache entries or unknown unloaded sessions.
{
value.clearCurrentSession()
value.rememberSelection("code", first, "high")
const projects = createProjectRegistry({ persisted: {}, activeId: () => "one" })
projects.ensure("one").tabs.set(["pending:project-one"])
projects.ensure("two").tabs.set(["pending:project-two", "background-empty", "unknown-unloaded"])
const untrack = value.trackScopes(projects.scopes)
await emit({ type: "messagesLoaded", sessionID: "background-empty", messages: [] })
await emit({ type: "messagesLoaded", sessionID: "closed-history-cache", messages: [] })
value.rememberSelection("code", personal, "low")
for (const id of ["pending:project-one", "pending:project-two", "background-empty"]) {
choice(value.selected(id), first)
assert.equal(value.currentVariant(id), "high")
}
choice(value.selected("closed-history-cache"), personal)
await emit({
type: "messagesLoaded",
sessionID: "unknown-unloaded",
messages: [
{
id: "unknown-message",
sessionID: "unknown-unloaded",
role: "user",
agent: "code",
model: { ...outgoing, variant: "high" },
createdAt: info("unknown-unloaded").createdAt,
},
],
})
choice(value.selected("unknown-unloaded"), outgoing)
assert.equal(value.currentVariant("unknown-unloaded"), "high")
projects.prune(new Set(["one"]))
await settle()
choice(value.selected("pending:project-two"), personal)
choice(value.selected("pending:project-one"), first)
untrack()
await settle()
choice(value.selected("pending:project-one"), personal)
value.rememberSelection("code", outgoing, "low")
choice(value.selected("closed-history-cache"), outgoing)
choice(value.selected("background-empty"), first)
}
// Repeated goal promotion removes empty draft caches without overwriting arriving session history.
for (const [index, args] of ["pause", "do X"].entries()) {
value.clearCurrentSession()
const size = Object.keys(value.allMessages()).length
assert.equal(value.sendCommand("goal", args), true)
const request = sent.findLast((message) => message.type === "sendCommand")
assert(request?.draftID)
assert.deepEqual(unwrap(value.allMessages()[request.draftID]), [])
const sid = `goal-cache-${index}`
const messages =
index === 0
? []
: [
{
id: "goal-history",
sessionID: sid,
role: "user" as const,
agent: "code",
model: first,
createdAt: info(sid).createdAt,
},
]
if (messages.length) await emit({ type: "messagesLoaded", sessionID: sid, messages })
await emit({ type: "sessionCreated", session: info(sid), draftID: request.draftID })
assert.equal(Object.hasOwn(value.allMessages(), request.draftID), false)
assert.deepEqual(unwrap(value.allMessages()[sid]), messages)
assert.equal(Object.keys(value.allMessages()).length, size + 1, "Only the promoted session cache should remain")
await emit({ type: "sessionCommandCompleted", messageID: request.messageID })
}
assert.deepEqual(failures, [])
} finally {
const before = state("background")
@@ -0,0 +1,343 @@
import { afterEach, describe, expect, it } from "bun:test"
import { link, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import type { PersistModelSelectionRequest } from "../../webview-ui/src/types/messages/webview-messages"
const dirs: string[] = []
const model = { providerID: "kilo", modelID: "model-a" }
const other = { providerID: "other", modelID: "model-b" }
const unrelated = {
recent: [other],
favorite: [model],
variant: { "kilo/model-a": "low" },
custom: { nested: [1, "keep", null] },
}
function load(): Promise<typeof import("../../src/kilo-provider/model-state")> {
// Reload module-level path caching without replacing the persistence implementation.
return import(`../../src/kilo-provider/model-state.ts?${crypto.randomUUID()}`)
}
async function fixture(data?: unknown, prefs?: unknown) {
const dir = await mkdtemp(join(tmpdir(), "kilo-model-state-"))
dirs.push(dir)
const file = join(dir, "model.json")
const preference = join(dir, "vscode-model.json")
if (data !== undefined) await Bun.write(file, JSON.stringify(data))
if (prefs !== undefined) await Bun.write(preference, JSON.stringify(prefs))
const client = { path: { get: async () => ({ data: { state: dir } }) } } as unknown as KiloClient
const host = await load()
const messages: unknown[] = []
const post = (message: unknown) => {
messages.push(message)
}
return {
dir,
get file() {
return Bun.file(file)
},
get preference() {
return Bun.file(preference)
},
client,
host,
post,
messages,
save: (selection: Omit<PersistModelSelectionRequest, "type">) =>
host.handleMessage("persistModelSelection", selection, client, post),
request: async () => {
expect(await host.handleMessage("requestModelSelections", {}, client, post)).toBe(true)
return messages.at(-1)
},
}
}
afterEach(async () => {
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
})
describe("model-state", () => {
it("persists a preferred model and effort together while preserving other modes and unrelated fields", async () => {
const state = await fixture({ ...unrelated, model: { code: other, plan: other } })
expect(await state.save({ agent: "code", ...model, variant: "high" })).toBe(true)
expect(await state.file.json()).toEqual({
...unrelated,
model: { code: model, plan: other },
})
expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "high" } })
expect(await state.request()).toEqual({
type: "modelSelectionsLoaded",
selections: { code: model, plan: other },
preferred: { ...model, variant: "high" },
})
})
it.each([undefined, ""])("saves an explicit default effort for variant %j", async (variant) => {
const state = await fixture({ model: { plan: other } }, { preferred: { ...other, variant: "high" } })
await state.save({ agent: "code", ...model, ...(variant === undefined ? {} : { variant }) })
expect(await state.file.json()).toEqual({
model: { plan: other, code: model },
})
expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "" } })
})
it("loads the saved combo after the host module reloads", async () => {
const state = await fixture()
await state.save({ agent: "code", ...model, variant: "high" })
const host = await load()
expect(await host.handleMessage("requestModelSelections", {}, state.client, state.post)).toBe(true)
expect(state.messages).toEqual([
{ type: "modelSelectionsLoaded", selections: { code: model }, preferred: { ...model, variant: "high" } },
])
})
it("atomically replaces the complete preferred combo without modifying the previous file", async () => {
const prefs = { preferred: { ...other, variant: "low" } }
const state = await fixture({ model: { plan: other } }, prefs)
const previous = join(state.dir, "previous.json")
await link(join(state.dir, "vscode-model.json"), previous)
await state.save({ agent: "code", ...model, variant: "high" })
expect(await Bun.file(previous).json()).toEqual(prefs)
expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "high" } })
})
it("loads legacy model memory without inventing a preferred combo", async () => {
const state = await fixture({ model: { code: model, bad: { providerID: 42, modelID: "invalid" } } })
expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model } })
})
it.each([undefined, "", "high"])(
"validates and sanitizes a loaded preferred combo with variant %j",
async (variant) => {
const preferred = { ...model, ...(variant === undefined ? {} : { variant }) }
const data = { model: { plan: other } }
const prefs = { preferred: { ...preferred, extra: "discard" } }
const state = await fixture(data, prefs)
expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { plan: other }, preferred })
expect(await state.file.json()).toEqual(data)
expect(await state.preference.json()).toEqual(prefs)
},
)
it.each(
[
null,
true,
42,
"invalid",
[],
{},
{ providerID: "kilo" },
{ modelID: "model-a" },
{ ...model, providerID: 42 },
{ ...model, providerID: "" },
{ ...model, modelID: null },
{ ...model, modelID: "" },
{ ...model, variant: null },
{ ...model, variant: 42 },
{ ...model, variant: false },
{ ...model, variant: {} },
{ ...model, variant: [] },
].map((preferred) => [preferred] as const),
)("omits invalid preferred data %j without discarding model memory", async (preferred) => {
const state = await fixture({ model: { code: model } }, { preferred })
expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model } })
})
it.each(["{", "null", "[]", '"invalid"'])("recovers from invalid model.json contents %s", async (raw) => {
const preferred = { ...model, variant: "high" }
const state = await fixture(undefined, { preferred })
await Bun.write(state.file, raw)
expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: {}, preferred })
await state.save({ agent: "code", ...model, variant: "high" })
expect(await state.file.json()).toEqual({ model: { code: model } })
expect(await state.preference.json()).toEqual({ preferred })
})
it.each(["{", "null", "[]", '"invalid"'])("ignores invalid vscode-model.json contents %s", async (raw) => {
const state = await fixture({ model: { code: model } })
await Bun.write(state.preference, raw)
expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model } })
await state.save({ agent: "code", ...model, variant: "high" })
expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "high" } })
})
it.each([
{ agent: "", ...model },
{ agent: 42, ...model },
{ agent: "code", modelID: "model-a" },
{ agent: "code", ...model, modelID: null },
{ agent: "code", ...model, variant: null },
{ agent: "code", ...model, variant: 42 },
])("ignores invalid explicit selections %j without overwriting saved preferences", async (message) => {
const data = { ...unrelated, model: { plan: other } }
const prefs = { preferred: { ...other, variant: "high" } }
const state = await fixture(data, prefs)
expect(await state.host.handleMessage("persistModelSelection", message, state.client, state.post)).toBe(true)
expect(await state.file.json()).toEqual(data)
expect(await state.preference.json()).toEqual(prefs)
})
it("returns empty memory for a missing file and creates it on an explicit save", async () => {
const state = await fixture()
expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: {} })
expect(await state.file.exists()).toBe(false)
expect(await state.preference.exists()).toBe(false)
await state.save({ agent: "code", ...model })
expect(await state.file.json()).toEqual({ model: { code: model } })
expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "" } })
})
it("clears model memory and preferred together while retaining unrelated fields", async () => {
const state = await fixture({ ...unrelated, model: { code: model } }, { preferred: { ...model, variant: "high" } })
await state.host.reset(state.client, state.post)
expect(await state.file.json()).toEqual({ ...unrelated, model: {} })
expect(await state.preference.json()).toEqual({})
expect(state.messages).toEqual([{ type: "modelSelectionsLoaded", selections: {} }])
const host = await load()
await host.handleMessage("requestModelSelections", {}, state.client, state.post)
expect(state.messages.at(-1)).toEqual({ type: "modelSelectionsLoaded", selections: {} })
})
it("preserves every model entry during concurrent saves and keeps the latest preferred combo", async () => {
const state = await fixture({ ...unrelated, model: { existing: other } })
const choices = Array.from({ length: 12 }, (_, index) => ({
agent: `mode-${index}`,
providerID: "kilo",
modelID: `model-${index}`,
variant: `effort-${index}`,
}))
await Promise.all([
...choices.map((choice) => state.save(choice)),
state.save({ agent: "mode-0", ...other, variant: "" }),
])
expect(await state.file.json()).toEqual({
...unrelated,
model: {
existing: other,
...Object.fromEntries(
choices.map((choice) => [choice.agent, { providerID: choice.providerID, modelID: choice.modelID }]),
),
"mode-0": other,
},
})
expect(await state.preference.json()).toEqual({ preferred: { ...other, variant: "" } })
})
it("waits for pending persistence before returning model selections", async () => {
const state = await fixture({ model: { plan: other } })
const saving = state.save({ agent: "code", ...model, variant: "high" })
const loaded = await state.request()
await saving
expect(loaded).toEqual({
type: "modelSelectionsLoaded",
selections: { plan: other, code: model },
preferred: { ...model, variant: "high" },
})
})
it("orders reset with pending saves without resurrecting cleared model memory", async () => {
const state = await fixture({ ...unrelated, model: { existing: other } })
await Promise.all([
state.save({ agent: "code", ...model, variant: "high" }),
state.host.reset(state.client, state.post),
state.save({ agent: "plan", ...other }),
])
expect(await state.file.json()).toEqual({
...unrelated,
model: { plan: other },
})
expect(await state.preference.json()).toEqual({ preferred: { ...other, variant: "" } })
})
it.each(["high", ""])("retains preferred effort %j after a TUI rewrite of shared model state", async (variant) => {
const state = await fixture()
const preferred = { ...model, variant }
await state.save({ agent: "code", ...preferred })
const before = await state.preference.text()
const shared = { model: { plan: other }, recent: [other], favorite: [], variant: { "other/model-b": "low" } }
await Bun.write(state.file, JSON.stringify(shared))
const host = await load()
await host.handleMessage("requestModelSelections", {}, state.client, state.post)
expect(state.messages).toEqual([{ type: "modelSelectionsLoaded", selections: { plan: other }, preferred }])
expect(await state.preference.text()).toBe(before)
expect(await state.file.json()).toEqual(shared)
})
it("does not read preferred metadata from the shared TUI file", async () => {
const state = await fixture({ model: { code: model }, preferred: { ...other, variant: "high" } })
expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model } })
})
it("keeps saving to the resolved state directory during a temporary disconnect", async () => {
const state = await fixture()
await state.request()
await state.host.handleMessage(
"persistModelSelection",
{ agent: "code", ...model, variant: "high" },
null,
state.post,
)
expect(await state.preference.json()).toEqual({ preferred: { ...model, variant: "high" } })
await state.host.handleMessage("requestModelSelections", {}, null, state.post)
expect(state.messages.at(-1)).toEqual({
type: "modelSelectionsLoaded",
selections: { code: model },
preferred: { ...model, variant: "high" },
})
})
it("defers a cold-start request without a client until a ready-client retry", async () => {
const preferred = { ...model, variant: "high" }
const state = await fixture({ model: { code: model } }, { preferred })
expect(await state.host.handleMessage("requestModelSelections", {}, null, state.post)).toBe(true)
expect(state.messages).toEqual([])
expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model }, preferred })
})
it.each(["missing", "error"])("defers loading when the CLI state path is unavailable (%s)", async (reason) => {
const preferred = { ...model, variant: "high" }
const state = await fixture({ model: { code: model } }, { preferred })
const client = {
path: {
get: async () => {
if (reason === "error") throw new Error("CLI is not ready")
return { data: {} }
},
},
} as unknown as KiloClient
expect(await state.host.handleMessage("requestModelSelections", {}, client, state.post)).toBe(true)
expect(state.messages).toEqual([])
expect(await state.request()).toEqual({ type: "modelSelectionsLoaded", selections: { code: model }, preferred })
})
})
@@ -36,6 +36,14 @@ describe("resolveVersionModels", () => {
})
describe("buildInitialMessages", () => {
test.each(["high", ""])("preserves model, agent, and effort %s without an initial prompt", (variant) => {
const model = { providerID: "a", modelID: "m1" }
const messages = buildInitialMessages(created(1), [], model, undefined, "plan", variant)
expect(messages.at(0)).toEqual({ sessionId: "ses-0", worktreeId: "wt-0", ...model, agent: "plan", variant })
const comparisons = buildInitialMessages(created(1), [{ ...model, variant }], {}, "", "plan", "low")
expect(comparisons.at(0)).toEqual(messages.at(0))
})
test("per-allocation variant wins over the dialog-level variant", () => {
const models = resolveVersionModels(
[
@@ -47,6 +47,7 @@ function check(code: string) {
import { plugin } from "bun"
import { isModelValid } from "./src/context/provider-utils.ts"
import { toggleModel, setAllocationVariant } from "./agent-manager/multi-model-utils.ts"
import { DEFAULT_VARIANT } from "./src/context/session-variant-store.ts"
const solid = join(dirname(require.resolve("solid-js")), "solid.js")
plugin({
@@ -55,8 +56,8 @@ function check(code: string) {
build.onResolve({ filter: /^solid-js$/ }, () => ({ path: solid }))
},
})
const { batch, createComputed, createRoot, createSignal } = await import("solid-js")
const { createDialogModels } = await import("./agent-manager/new-worktree-models.ts")
const { batch, createComputed, createRoot, createSignal, onCleanup } = await import("solid-js")
const { createDialogModels, createDialogPreferences } = await import("./agent-manager/new-worktree-models.ts")
const x = { providerID: "kilo", modelID: "x" }
const y = { providerID: "kilo", modelID: "y" }
@@ -87,7 +88,39 @@ function check(code: string) {
createComputed(() => seen.push(state.model()))
return { state, snapshot, refresh: (update) => refresh((current) => ({ ...current, ...update })), switchAgent, seen }
}
createRoot((dispose) => {
// Import the same reactive preference controller used by the dialog, without rendering unrelated UI.
function dialog(saved = {}, initial = { providers: catalog(x, y), fallback: y, alternate: y, ready: true, connected: [] }) {
const result = createRoot((dispose) => {
const [snapshot, refresh] = createSignal(initial)
const [compareMode, setCompareMode] = createSignal(false)
const preferences = []
const state = createDialogPreferences({
saved,
agent: saved.agent ?? "code",
ready: () => snapshot().ready,
valid: (value) => isModelValid(snapshot().providers, snapshot().connected, value),
variants: (value) => Object.keys(snapshot().providers[value.providerID]?.models[value.modelID]?.variants ?? {}),
fallback: (name) => name === "code" ? snapshot().fallback : snapshot().alternate ?? null,
effort: (name, model) => model ? snapshot().efforts?.[name + "/" + model.modelID] ?? snapshot().efforts?.[name] : undefined,
preferred: () => snapshot().preferred,
hydrated: () => snapshot().hydrated ?? true,
compare: compareMode,
remember: (...args) => preferences.push(args),
})
return {
...state, setCompareMode, preferences,
pick: state.selectModel,
choose: state.selectVariant,
clear: () => state.selectVariant(DEFAULT_VARIANT),
cached: state.saved,
refresh: (update) => refresh((current) => ({ ...current, ...update })), dispose,
}
})
onCleanup(result.dispose)
return result
}
await createRoot(async (dispose) => {
try {
${code}
} finally {
@@ -104,23 +137,24 @@ function check(code: string) {
}
describe("NewWorktreeDialog models", () => {
it("persists only the saved choice and wires the effective model to display, variants, and guarded submission", () => {
expect(src).toContain("saved: saved.model,")
expect(src).toContain("fallback: () => session.modelForAgent(agent()),")
expect(src).toContain("ready: provider.ready,")
expect(src).toContain("const model = selection.model")
expect(src).toContain("model: selection.choice(),")
expect(src).not.toContain("model: model(),")
expect(src).toContain("selection.select(undefined)")
expect(src).not.toContain("setModel(")
expect(src).toContain("selection.select(next)")
expect(src).toContain("value={model()}")
expect(src).toContain("const sel = model()")
expect(src).toContain("session.variantForAgent(agent(), model())")
expect(src).toContain("const sel = isCompare ? null : model()")
expect(src).toContain("return selection.canSubmit(compareMode() ? modelAllocations() : undefined)")
expect(src).toContain("if (!canSubmit()) return")
expect(src).toContain("disabled={!canSubmit()}")
it("caches only explicit model choices and guards submission using the available model", () => {
check(`
const state = dialog()
await Promise.resolve()
assert.deepEqual(state.model(), y)
assert.deepEqual(state.cached(), { agent: "code", model: undefined, variant: undefined })
assert.equal(state.selection.canSubmit(), true)
state.refresh({ providers: {}, ready: false })
assert.equal(state.model(), null)
assert.equal(state.cached().model, undefined)
assert.equal(state.selection.canSubmit(), false)
state.refresh({ providers: catalog(x, y), ready: true })
state.pick("kilo", "x")
assert.deepEqual(state.model(), x)
assert.deepEqual(state.cached(), { agent: "code", model: x, variant: "" })
assert.equal(state.selection.canSubmit(), true)
assert.deepEqual(state.preferences, [["code", x, ""]])
`)
})
it("keeps saved X through reactive X to Y to X catalog changes", () => {
@@ -137,6 +171,280 @@ describe("NewWorktreeDialog models", () => {
`)
})
it("keeps an explicit model and effort through real dialog mode switches", () => {
check(`
const state = dialog({ model: x, variant: "high" })
await Promise.resolve()
state.selectAgent("plan")
assert.deepEqual(state.model(), x)
assert.equal(state.effectiveVariant(), "high")
assert.deepEqual(state.cached().model, x)
assert.equal(state.cached().variant, "high")
state.selectAgent("code")
assert.deepEqual(state.model(), x)
assert.equal(state.effectiveVariant(), "high")
assert.deepEqual(state.preferences, [])
`)
})
it.each([
[undefined, "high"],
["", ""],
])("uses the target model preference only when outgoing effort is %s", (value, expected) => {
check(`
const state = dialog({ model: x, variant: ${JSON.stringify(value)} }, {
providers: catalog(x, y), fallback: x, ready: true, connected: [], efforts: { "code/y": "high" },
})
state.pick("kilo", "y")
assert.equal(state.variant(), ${JSON.stringify(expected)})
assert.deepEqual(state.preferences, [["code", y, ${JSON.stringify(expected)}]])
`)
})
it("pins the displayed inherited model and effort on mode switch without saving a model preference", () => {
check(`
const state = dialog({}, {
providers: catalog(x, y), fallback: x, alternate: y, ready: true, connected: [],
efforts: { code: "high", plan: undefined },
})
await Promise.resolve()
state.selectAgent("plan")
assert.deepEqual(state.model(), x)
assert.equal(state.effectiveVariant(), "high")
state.refresh({ providers: catalog(y) })
assert.deepEqual(state.model(), y)
state.selectAgent("code")
state.refresh({ providers: catalog(x, y) })
assert.deepEqual(state.model(), x)
assert.equal(state.selection.choice(), undefined)
assert.equal(state.cached().model, undefined)
assert.deepEqual(state.preferences, [])
`)
})
it.each([
[undefined, "low"],
["", undefined],
["high", "high"],
])("keeps outgoing effort %s distinct from an unset choice on mode switch", (value, expected) => {
check(`
const providers = catalog(x)
providers.kilo.models.x.variants = { low: {}, high: {} }
const state = dialog(${value === "" ? '{ variant: "" }' : "{}"}, {
providers, fallback: x, alternate: x, ready: true, connected: [],
efforts: { code: ${JSON.stringify(value)}, plan: "low" },
})
await Promise.resolve()
state.selectAgent("plan")
assert.equal(state.effectiveVariant(), ${JSON.stringify(expected)})
assert.equal(state.variant(), ${JSON.stringify(value)})
assert.deepEqual(state.preferences, [])
`)
})
it.each(["", "high"])("keeps inherited raw effort %s through catalog changes and a mode switch", (value) => {
check(`
const providers = catalog(x)
providers.kilo.models.x.variants = { low: {} }
const state = dialog({}, {
providers, fallback: x, alternate: x, ready: true, connected: [],
efforts: { code: ${JSON.stringify(value)}, plan: "low" },
})
await Promise.resolve()
assert.equal(state.variant(), undefined)
assert.equal(state.effectiveVariant(), ${value === "" ? "undefined" : '"low"'})
state.selectAgent("plan")
assert.equal(state.variant(), ${JSON.stringify(value)})
state.refresh({ providers: catalog(x) })
assert.equal(state.effectiveVariant(), ${value === "" ? "undefined" : '"high"'})
assert.deepEqual(state.preferences, [])
`)
})
it("restores the saved effort after an empty catalog refresh and reopening", () => {
check(`
const state = dialog({ model: x, variant: "high" })
await Promise.resolve()
state.refresh({ providers: {}, ready: false })
assert.equal(state.model(), null)
assert.equal(state.effectiveVariant(), undefined)
assert.equal(state.cached().variant, "high")
const reopened = dialog(state.cached(), { providers: {}, fallback: y, ready: false, connected: [] })
await Promise.resolve()
assert.equal(reopened.cached().variant, "high")
reopened.refresh({ providers: catalog(x, y), ready: true })
assert.deepEqual(reopened.model(), x)
assert.equal(reopened.effectiveVariant(), "high")
assert.deepEqual(reopened.preferences, [])
`)
})
it("does not overwrite saved effort with a temporary catalog fallback's nearest effort", () => {
check(`
const state = dialog({ model: x, variant: "high" })
await Promise.resolve()
const providers = catalog(y)
providers.kilo.models.y.variants = { low: {} }
state.refresh({ providers })
assert.deepEqual(state.model(), y)
assert.equal(state.effectiveVariant(), "low")
assert.equal(state.cached().variant, "high")
state.selectAgent("plan")
state.refresh({ providers: catalog(x, y) })
assert.deepEqual(state.model(), x)
assert.equal(state.effectiveVariant(), "high")
assert.deepEqual(state.preferences, [])
`)
})
it("carries the saved effort when explicitly picking a model during a temporary fallback", () => {
check(`
const state = dialog({ model: x, variant: "high" })
await Promise.resolve()
const providers = catalog(y, z)
providers.kilo.models.y.variants = { low: {} }
providers.kilo.models.z.variants = { low: {}, high: {} }
state.refresh({ providers })
assert.equal(state.effectiveVariant(), "low")
state.pick("kilo", "z")
assert.deepEqual(state.model(), z)
assert.equal(state.effectiveVariant(), "high")
assert.deepEqual(state.preferences, [["code", z, "high"]])
`)
})
it("shares only explicit single-model picks and keeps explicit Default across mode switches", () => {
check(`
const state = dialog({}, {
providers: catalog(x, y), fallback: x, alternate: y, ready: true, connected: [],
efforts: { code: "high", plan: "high" },
})
await Promise.resolve()
assert.deepEqual(state.preferences, [])
state.pick("kilo", "y")
assert.deepEqual(state.preferences, [["code", y, "high"]])
state.clear()
assert.deepEqual(state.preferences.at(-1), ["code", y, ""])
state.selectAgent("plan")
assert.equal(state.effectiveVariant(), undefined)
assert.equal(state.variant(), "")
assert.equal(state.preferences.length, 2)
state.choose("high")
assert.deepEqual(state.preferences.at(-1), ["plan", y, "high"])
const reopened = dialog(state.cached())
await Promise.resolve()
assert.deepEqual(reopened.model(), y)
assert.equal(reopened.effectiveVariant(), "high")
assert.deepEqual(reopened.preferences, [])
`)
})
it("starts with the latest shared preference rather than stale dialog choices without following later updates", () => {
check(`
const state = dialog({ model: x, variant: "high" }, {
providers: catalog(x, y), fallback: x, ready: true, connected: [], preferred: { ...y, variant: "" },
})
await Promise.resolve()
assert.deepEqual(state.model(), y)
assert.equal(state.effectiveVariant(), undefined)
assert.equal(state.variant(), "")
state.refresh({ preferred: { ...x, variant: "high" } })
assert.deepEqual(state.model(), y)
assert.equal(state.variant(), "")
assert.deepEqual(state.preferences, [])
`)
})
it("adopts a delayed first hydrated preference once without following later shared updates", () => {
check(`
const providers = catalog(x, y, z)
providers.kilo.models.y.variants = { low: {}, high: {} }
const state = dialog({ model: x, variant: "high" }, {
providers, fallback: x, ready: true, connected: [], hydrated: false,
})
await Promise.resolve()
state.refresh({ preferred: { ...y, variant: "low" } })
assert.deepEqual(state.model(), x)
assert.equal(state.effectiveVariant(), "high")
state.refresh({ hydrated: true })
assert.deepEqual(state.model(), y)
assert.equal(state.effectiveVariant(), "low")
assert.deepEqual(state.cached().model, y)
assert.equal(state.cached().variant, "low")
state.refresh({ preferred: { ...z, variant: "high" } })
assert.deepEqual(state.model(), y)
assert.equal(state.effectiveVariant(), "low")
assert.deepEqual(state.preferences, [])
`)
})
it("retains cached choices when first hydration has no preference and ignores a later preference", () => {
check(`
const state = dialog({ model: x, variant: "high" }, {
providers: catalog(x, y), fallback: y, ready: true, connected: [], hydrated: false,
})
await Promise.resolve()
state.refresh({ hydrated: true })
assert.deepEqual(state.model(), x)
assert.equal(state.effectiveVariant(), "high")
state.refresh({ preferred: { ...y, variant: "" } })
assert.deepEqual(state.model(), x)
assert.equal(state.effectiveVariant(), "high")
assert.deepEqual(state.cached().model, x)
assert.equal(state.cached().variant, "high")
assert.deepEqual(state.preferences, [])
`)
})
it.each(["model", "variant", "default", "mode"])(
"preserves a %s interaction before initial preferences arrive",
(action) => {
check(`
const state = dialog({ model: x, variant: "high" }, {
providers: catalog(x, y, z), fallback: x, alternate: y, ready: true, connected: [], hydrated: false,
})
const actions = {
model: () => state.pick("kilo", "z"),
variant: () => state.choose("high"),
default: state.clear,
mode: () => state.selectAgent("plan"),
}
actions[${JSON.stringify(action)}]()
await Promise.resolve()
const expected = state.model()
const effort = state.variant()
const writes = state.preferences.length
state.refresh({ hydrated: true, preferred: { ...y, variant: "" } })
assert.deepEqual(state.model(), expected)
assert.equal(state.variant(), effort)
assert.deepEqual(state.cached().model, expected)
assert.equal(state.cached().variant, effort)
assert.equal(state.preferences.length, writes)
`)
},
)
it("pins a model on explicit effort selection but never shares comparison choices", () => {
check(`
const state = dialog()
await Promise.resolve()
state.choose("high")
assert.deepEqual(state.preferences, [["code", y, "high"]])
state.refresh({ fallback: x })
assert.deepEqual(state.model(), y)
state.setCompareMode(true)
const allocations = setAllocationVariant(toggleModel(new Map(), "kilo", "x", "X"), "kilo", "x", "high")
assert.equal(state.selection.canSubmit(allocations), true)
state.choose(undefined)
assert.equal(state.preferences.length, 1)
state.selectAgent("plan")
state.refresh({ ready: false })
state.refresh({ ready: true })
assert.deepEqual(state.preferences, [["code", y, "high"]])
assert.deepEqual(state.selection.choice(), y)
`)
})
it("restores an initially unavailable cached X without replacing it with Y", () => {
check(`
const { state, refresh } = scene(x, { providers: catalog(y), fallback: y, ready: true, connected: [] })
@@ -158,17 +466,15 @@ describe("NewWorktreeDialog models", () => {
const { state, refresh, switchAgent, seen } = scene(undefined)
assert.deepEqual(state.model(), y)
assert.equal(state.choice(), undefined)
state.select(y)
assert.deepEqual(state.choice(), y)
refresh({ providers: catalog(y, z), alternate: z })
batch(() => {
state.retain()
switchAgent("plan")
state.select(undefined)
})
assert.deepEqual(state.model(), z)
assert.deepEqual(state.model(), y)
assert.equal(state.choice(), undefined)
refresh({ providers: catalog(x), alternate: x })
assert.deepEqual(seen, [y, z, x])
assert.deepEqual(seen, [y, x])
assert.equal(state.choice(), undefined)
`)
})
@@ -90,7 +90,7 @@ describe("sendCommand dismisses pending tool requests", () => {
expect(body).toContain("selectAgent(overrides.agent, scope)")
expect(body).toContain("if (overrides?.model)")
expect(body).toContain("selectModel(effectiveSelection.providerID, effectiveSelection.modelID, scope)")
expect(body).toContain("if (overrides?.variant)")
expect(body).toContain("if (overrides?.variant !== undefined)")
expect(body).toContain("selectVariant(overrides.variant, scope)")
})
})
@@ -322,7 +322,7 @@ describe("sendMessage / sendCommand draft id contract", () => {
it("sendCommand seeds the pending agent before resolving draft-scoped settings", () => {
const body = extractFunctionBody(source, "sendCommand")
expect(body).toMatch(
/if \(!sid && !draftID && effectiveDraftID\) agentDrafts\.seed\(effectiveDraftID\)[\s\S]*submission\(scope, effectiveSelection\)/,
/if \(!sid && !draftID && effectiveDraftID\) \{\s*agentDrafts\.seed\(effectiveDraftID\)[\s\S]*submission\(scope, effectiveSelection\)/,
)
})
@@ -156,14 +156,14 @@ describe("per-mode model memory", () => {
expect(getAgentModel(store, env(), "ask")).toEqual(gpt)
})
it("ignores stale remembered selections when a configured mode model is user-set", () => {
it("keeps an explicit remembered model ahead of the configured mode default", () => {
const configured: ResolveEnv = {
...env(),
getModeModel: (name) => (name === "code" ? claude : null),
}
const store = { ...emptyStore(), modelSelections: { code: gpt } }
expect(getAgentModel(store, configured, "code", true)).toEqual(claude)
expect(getAgentModel(store, configured, "code", true)).toEqual(gpt)
})
it("applyModel in a session writes only to sessionOverrides", () => {
@@ -174,7 +174,7 @@ describe("per-mode model memory", () => {
expect(result.modelSelections["code"]).toBeUndefined()
})
it("switching modes falls back to default after session override is cleared", () => {
it("keeps a session override when its selected mode changes", () => {
let store = emptyStore()
const e = env()
@@ -182,10 +182,8 @@ describe("per-mode model memory", () => {
const result = applyModel(store, "code", claude, "session-a")
store = { ...store, ...result }
// Simulate mode switch: clear session override (like selectAgent does)
const cleared = { ...store, sessionOverrides: {} }
expect(getSelected(cleared, e, "session-a", "code")).toEqual(KILO_AUTO)
const switched = { ...store, agentSelections: { "session-a": "ask" } }
expect(getSessionModel(switched, e, "session-a", "code")).toEqual(claude)
})
it("different modes remember their own model independently", () => {
@@ -234,7 +232,7 @@ describe("per-mode model memory", () => {
expect(Object.keys(result.sessionOverrides)).toHaveLength(0)
})
it("switching from plan to implementation uses implementation config after clearing stale memory", () => {
it("keeps a plan session's explicit model when switching to configured implementation mode", () => {
let store = emptyStore()
const configured: ResolveEnv = {
...env(),
@@ -252,11 +250,9 @@ describe("per-mode model memory", () => {
const switched: ModelStore = {
...store,
agentSelections: { "session-a": "code" },
sessionOverrides: {},
modelSelections: { ...store.modelSelections, code: null },
}
expect(getSelected(switched, configured, "session-a", "code")).toEqual(gpt)
expect(getSelected(switched, configured, "session-a", "code")).toEqual(claude)
})
})
@@ -338,10 +334,10 @@ describe("organization model store", () => {
expect(getSessionModel(store, { ...organization, connected: [] }, "session-a", "code")).toEqual(recommendation)
})
it("keeps Agent Manager mode configuration precedence without destroying the manual choice", () => {
it("honors the same explicit choice in Agent Manager and the chat picker", () => {
const store = { ...emptyStore(), modelSelections: { code: KILO_AUTO }, userSetAgents: { code: true } }
const configured = { ...organization, getModeModel: () => first, getGlobalModel: () => gpt }
expect(getAgentModel(store, configured, "code")).toEqual(first)
expect(getAgentModel(store, configured, "code")).toEqual(KILO_AUTO)
expect(getSelected(store, configured, undefined, "code")).toEqual(KILO_AUTO)
expect(store.modelSelections.code).toEqual(KILO_AUTO)
expect(getAgentModel(store, organization, "code")).toEqual(KILO_AUTO)
@@ -352,4 +348,24 @@ describe("organization model store", () => {
expect(getAgentModel(store, { ...organization, getModeModel: () => first }, "code")).toEqual(first)
expect(getSelected(store, { ...organization, getGlobalModel: () => gpt }, undefined, "code")).toEqual(gpt)
})
it("uses the latest explicit default across modes without changing a session override", () => {
const store: ModelStore = {
...emptyStore(),
preferred: gpt,
modelSelections: { code: KILO_AUTO, ask: first },
userSetAgents: { code: true, ask: true },
sessionOverrides: { active: claude },
}
const configured = { ...organization, getModeModel: () => first, getGlobalModel: () => KILO_AUTO }
for (const agent of ["code", "ask"]) {
expect(getAgentModel(store, configured, agent)).toEqual(gpt)
expect(getSelected(store, configured, undefined, agent)).toEqual(gpt)
expect(getSelected(store, configured, "active", agent)).toEqual(claude)
}
const unavailable = { ...configured, connected: ["kilo"] }
expect(getAgentModel(store, unavailable, "code")).toEqual(KILO_AUTO)
expect(getAgentModel(store, configured, "code")).toEqual(gpt)
expect(store.preferred).toEqual(gpt)
})
})
@@ -0,0 +1,20 @@
import { describe, expect, it } from "bun:test"
import path from "node:path"
describe("session preference loader", () => {
it.each(["exhaustion", "offline", "reconnect", "retry", "ready", "cleanup", "loaded", "synchronous"])(
"%s uses the production helper with browser Solid reactivity",
(name) => {
const child = Bun.spawnSync(
[
process.execPath,
"--conditions=browser",
path.join(import.meta.dir, "../fixtures/session-preference-loader.ts"),
name,
],
{ cwd: path.join(import.meta.dir, "../../webview-ui"), stdout: "pipe", stderr: "pipe" },
)
expect(child.exitCode, child.stdout.toString() + child.stderr.toString()).toBe(0)
},
)
})
@@ -57,11 +57,11 @@ describe("per-session variant selection", () => {
})
it.each(["anthropic/claude-sonnet-4", variantKey(model, "code")])(
"prefers the configured variant over the remembered preference %s",
"preserves the remembered preference %s above the configured variant",
(key) => {
const store = { [key]: "high" }
expect(getVariant(store, model, ["high", "max"], "code", "pending-new", "max")).toBe("max")
expect(getAgentVariant(store, model, { variants: { high: {}, max: {} } }, "code", "max")).toBe("max")
expect(getVariant(store, model, ["high", "max"], "code", "pending-new", "max")).toBe("high")
expect(getAgentVariant(store, model, { variants: { high: {}, max: {} } }, "code", "max")).toBe("high")
},
)
@@ -8,6 +8,7 @@ function setup(session?: string, configured?: string) {
const config = { model: "anthropic/claude-sonnet-4", variant: configured }
const selections: Record<string, string> = {}
const messages: Array<{ type: string; key?: string; value?: string }> = []
const remembered: Array<{ agent: string; model: ModelSelection; variant: string }> = []
const order: string[] = []
let handler: ((message: ExtensionMessage) => void) | undefined
const variants = createSessionVariants({
@@ -20,6 +21,7 @@ function setup(session?: string, configured?: string) {
agent: () => "code",
config: () => config,
find: () => ({ variants: { low: {}, high: {}, max: {} } }),
remember: (agent, model, variant) => remembered.push({ agent, model, variant }),
post: (message) => {
order.push("post")
messages.push(message)
@@ -30,10 +32,37 @@ function setup(session?: string, configured?: string) {
return () => order.push("unsub")
},
})
return { variants, config, selections, messages, order, dispatch: (message: ExtensionMessage) => handler?.(message) }
return {
variants,
config,
selections,
messages,
remembered,
order,
dispatch: (message: ExtensionMessage) => handler?.(message),
}
}
describe("session variants", () => {
it("distinguishes an unset effort from an explicit Default selection", () => {
const state = setup()
expect(state.variants.saved(model, "code")).toBeUndefined()
expect(state.variants.choice()).toBeUndefined()
expect(state.variants.request()).toBe("")
state.variants.select("")
expect(state.variants.saved(model, "code")).toBe("")
expect(state.variants.choice()).toBe("")
expect(state.variants.request()).toBe("")
})
it.each([undefined, "session-a"])("carries explicit Default rather than the target preference for %s", (id) => {
const state = setup(id, "max")
state.selections["agent/code/anthropic/claude-sonnet-4"] = "high"
state.variants.carry(model, "", "code", id)
expect(state.variants.current(id)).toBeUndefined()
expect(state.variants.request(id)).toBe("")
})
it("subscribes before requesting persisted variants and returns cleanup", () => {
const state = setup()
const unsub = state.variants.load()
@@ -60,14 +89,14 @@ describe("session variants", () => {
expect(state.variants.request()).toBe("max")
})
it("uses updated configuration ahead of remembered defaults for new tabs", () => {
it("keeps remembered effort when configuration changes for new tabs", () => {
const state = setup("pending-new", "high")
state.selections["agent/code/anthropic/claude-sonnet-4"] = "low"
expect(state.variants.current()).toBe("high")
expect(state.variants.current()).toBe("low")
state.config.variant = "max"
expect(state.variants.current()).toBe("max")
expect(state.variants.request()).toBe("max")
expect(state.variants.agent("code", model)).toBe("max")
expect(state.variants.current()).toBe("low")
expect(state.variants.request()).toBe("low")
expect(state.variants.agent("code", model)).toBe("low")
})
it("does not apply a configured variant to another model", () => {
@@ -86,26 +115,25 @@ describe("session variants", () => {
expect(state.variants.request("session-b")).toBe("max")
})
it.each(["sidebar-pending:new", "pending:new"])("keeps a pre-submit Default choice scoped to %s", (id) => {
it.each(["sidebar-pending:new", "pending:new"])("remembers a pre-submit Default choice from %s", (id) => {
const state = setup(undefined, "max")
state.variants.select(undefined, id)
expect(state.variants.current(id)).toBeUndefined()
expect(state.variants.request(id)).toBe("")
expect(state.variants.current("another-draft")).toBe("max")
expect(state.remembered).toEqual([{ agent: "code", model, variant: "" }])
expect(state.messages).toEqual([])
})
it("persists global selections but keeps session selections local", () => {
const global = setup()
global.variants.select("high")
expect(global.messages).toEqual([
{ type: "persistVariant", key: "agent/code/anthropic/claude-sonnet-4", value: "high" },
])
expect(global.remembered).toEqual([{ agent: "code", model, variant: "high" }])
const scoped = setup("session-a")
scoped.variants.select("low")
expect(scoped.selections).toEqual({ "session/session-a/anthropic/claude-sonnet-4": "low" })
expect(scoped.messages).toEqual([])
expect(scoped.remembered).toEqual([])
})
it("persists an explicit default selection", () => {
@@ -114,7 +142,7 @@ describe("session variants", () => {
state.variants.select(undefined)
expect(state.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "" })
expect(state.variants.current()).toBeUndefined()
expect(state.messages).toEqual([{ type: "persistVariant", key: "agent/code/anthropic/claude-sonnet-4", value: "" }])
expect(state.remembered).toEqual([{ agent: "code", model, variant: "" }])
})
it("does not shadow a cached variant when carrying the model default", () => {
@@ -305,6 +305,7 @@ const AgentManagerContent: Component = () => {
persisted: persisted ?? {},
activeId: () => currentProjectId() ?? "single",
})
onCleanup(session.trackScopes(registry.scopes))
const defaultBase = (id: string) =>
projectDefaultBase(registry.ensure(id), id === activeProjectId(), repoDetectedBranch())
const localSessionIDs = () => registry.active().tabs.ids()
@@ -24,7 +24,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 { DEFAULT_VARIANT, cycleVariant, preserveVariant } from "../src/context/session-variant-store"
import { DEFAULT_VARIANT, cycleVariant } 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"
@@ -51,7 +51,7 @@ import { tracker } from "./telemetry"
import { cycleAgent } from "../src/context/session-agent"
import type { ModeRouter } from "./mode-router"
import { ProjectSelect } from "./ProjectSelect"
import { createDialogModels } from "./new-worktree-models"
import { createDialogPreferences } from "./new-worktree-models"
import { validBranch } from "./new-worktree-branch"
type VersionCount = 1 | 2 | 3 | 4
@@ -137,17 +137,22 @@ export const NewWorktreeDialog: Component<{
const [prompt, setPrompt] = createSignal((cached?.advancedDialogPrompt as string) ?? "")
const saved = readDialogSelections(cached?.advancedDialogSelections)
const [versions, setVersions] = createSignal<VersionCount>(1)
const [compareMode, setCompareMode] = createSignal(false)
const initialAgent = restoreAgent(saved.agent, session.agents(), session.selectedAgent())
const [agent, setAgent] = createSignal(initialAgent)
const selection = createDialogModels({
saved: saved.model,
fallback: () => session.modelForAgent(agent()),
const preferences = createDialogPreferences({
saved,
agent: initialAgent,
fallback: session.modelForAgent,
effort: session.variantPreference,
preferred: session.preferredSelection,
hydrated: session.preferencesReady,
ready: provider.ready,
valid: provider.isModelValid,
variants: (value) => Object.keys(provider.findModel(value)?.variants ?? {}),
compare: compareMode,
remember: session.rememberSelection,
})
const model = selection.model
const [compareMode, setCompareMode] = createSignal(false)
const { selection, model, agent, variants, effectiveVariant, selectAgent, selectModel, selectVariant } = preferences
const [modelAllocations, setModelAllocations] = createSignal<ModelAllocations>(new Map())
const [starting, setStarting] = createSignal(false)
const [enhancing, setEnhancing] = createSignal(false)
@@ -157,7 +162,6 @@ export const NewWorktreeDialog: Component<{
const [baseBranchOpen, setBaseBranchOpen] = createSignal(false)
const [compareOpen, setCompareOpen] = createSignal(false)
const [highlightedIndex, setHighlightedIndex] = createSignal(0)
const [variant, setVariant] = createSignal<string | undefined>(saved.variant)
const [sandbox, setSandbox] = createSignal<boolean | undefined>(saved.sandbox)
const [sandboxDefault, setSandboxDefault] = createSignal<boolean | undefined>()
const [sandboxOverride, setSandboxOverride] = createSignal<boolean | undefined>()
@@ -178,12 +182,6 @@ export const NewWorktreeDialog: Component<{
setEnhancing(false)
}
const selectAgent = (name: string) => {
setAgent(name)
selection.select(undefined)
setVariant(undefined)
}
const cycle = (direction: 1 | -1) => {
cycleAgent({
agents: session.agents(),
@@ -199,33 +197,6 @@ export const NewWorktreeDialog: Component<{
onCleanup(dispose)
})
// Variant list for the currently selected model
const variants = createMemo(() => {
const sel = model()
if (!sel) return []
const found = provider.findModel(sel)
if (!found?.variants) return []
return Object.keys(found.variants)
})
const effectiveVariant = createMemo(() => {
const list = variants()
if (list.length === 0) return undefined
const stored = variant() ?? session.variantForAgent(agent(), model())
return stored && list.includes(stored) ? stored : undefined
})
// Reset variant when model changes and stored variant is not in new list
createEffect(() => {
const list = variants()
if (list.length === 0) {
setVariant(undefined)
return
}
const stored = variant()
if (stored && !list.includes(stored)) setVariant(preserveVariant(stored, list))
})
createEffect(() => {
if (!sandboxVisible()) return
if (server.connectionState() !== "connected") {
@@ -300,9 +271,7 @@ export const NewWorktreeDialog: Component<{
vscode.setState({
...state,
advancedDialogSelections: {
agent: agent(),
model: selection.choice(),
variant: variant(),
...preferences.saved(),
sandbox: sandbox(),
},
})
@@ -489,7 +458,7 @@ export const NewWorktreeDialog: Component<{
if (list.length === 0) return
const next = cycleVariant(effectiveVariant(), list)
e.preventDefault()
setVariant(next ?? DEFAULT_VARIANT)
selectVariant(next)
return
}
undo(e)
@@ -830,14 +799,7 @@ export const NewWorktreeDialog: Component<{
<Show when={!compareMode()}>
<ModelSelectorBase
value={model()}
onSelect={(pid, mid) => {
if (!pid || !mid) return
const current = effectiveVariant()
const next = { providerID: pid, modelID: mid }
const list = Object.keys(provider.findModel(next)?.variants ?? {})
selection.select(next)
setVariant(preserveVariant(current, list) ?? DEFAULT_VARIANT)
}}
onSelect={selectModel}
onPick={restorePrompt}
onCancel={restorePrompt}
trigger={WORKTREE_PROMPT_SCOPE}
@@ -848,8 +810,8 @@ export const NewWorktreeDialog: Component<{
<ThinkingSelectorBase
variants={variants()}
value={effectiveVariant()}
onSelect={setVariant}
onClear={() => setVariant(DEFAULT_VARIANT)}
onSelect={selectVariant}
onClear={() => selectVariant(DEFAULT_VARIANT)}
allowClear
clearLabel={t("common.default")}
trigger={WORKTREE_PROMPT_SCOPE}
@@ -1,5 +1,6 @@
import { createMemo, createSignal } from "solid-js"
import { batch, createEffect, createMemo, createSignal } from "solid-js"
import type { ModelSelection } from "../src/types/messages"
import { DEFAULT_VARIANT, preserveVariant } from "../src/context/session-variant-store"
import { type ModelAllocations, MAX_MULTI_VERSIONS, totalAllocations } from "./multi-model-utils"
export function createDialogModels(opts: {
@@ -10,9 +11,10 @@ export function createDialogModels(opts: {
variants: (model: ModelSelection) => string[]
}) {
const [choice, select] = createSignal(opts.saved)
const [held, hold] = createSignal<ModelSelection | null>(null)
const valid = (value: ModelSelection) => (value.providerID !== "kilo" || opts.ready()) && opts.valid(value)
const model = createMemo(() => {
const saved = choice()
const saved = choice() ?? held()
if (saved && valid(saved)) return saved
const fallback = opts.fallback()
return fallback && valid(fallback) ? fallback : null
@@ -29,5 +31,98 @@ export function createDialogModels(opts: {
(entry.variant === undefined || opts.variants(entry).includes(entry.variant)),
)
}
return { choice, select, model, canSubmit }
const retain = () => {
// Mode switches retain the displayed default without turning it into a saved user preference.
if (!choice() && !held()) hold(model())
}
return { choice, select, model, canSubmit, retain }
}
export function createDialogPreferences(opts: {
saved: { model?: ModelSelection; variant?: string }
agent: string
fallback: (agent: string) => ModelSelection | null
effort: (agent: string, model: ModelSelection | null) => string | undefined
preferred: () => (ModelSelection & { variant?: string }) | undefined
hydrated: () => boolean
ready: () => boolean
valid: (model: ModelSelection) => boolean
variants: (model: ModelSelection) => string[]
compare: () => boolean
remember: (agent: string, model: ModelSelection, variant: string) => void
}) {
const preferred = opts.preferred()
let pending = !opts.hydrated()
const [agent, setAgent] = createSignal(opts.agent)
const [variant, setVariant] = createSignal(preferred ? preferred.variant : opts.saved.variant)
const selection = createDialogModels({
saved: preferred ? { providerID: preferred.providerID, modelID: preferred.modelID } : opts.saved.model,
fallback: () => opts.fallback(agent()),
ready: opts.ready,
valid: opts.valid,
variants: opts.variants,
})
const model = selection.model
const variants = createMemo(() => {
const value = model()
return value ? opts.variants(value) : []
})
const current = () => variant() ?? opts.effort(agent(), selection.choice() ?? model())
// Catalog refreshes may temporarily hide a model or effort. Never rewrite the saved choice.
const effectiveVariant = createMemo(() => preserveVariant(current(), variants()))
const selectAgent = (name: string) => {
pending = false
// Unset effort can inherit the next mode's default; an explicit Default must stay sticky.
const value = current()
batch(() => {
selection.retain()
setVariant(value)
setAgent(name)
})
}
const selectModel = (pid: string, mid: string) => {
if (!pid || !mid) return
pending = false
const next = { providerID: pid, modelID: mid }
const effort = preserveVariant(current() ?? opts.effort(agent(), next), opts.variants(next)) ?? DEFAULT_VARIANT
batch(() => {
selection.select(next)
setVariant(effort)
if (!opts.compare()) opts.remember(agent(), next, effort)
})
}
const selectVariant = (value: string | undefined) => {
pending = false
const next = value ?? DEFAULT_VARIANT
batch(() => {
setVariant(next)
const sel = model()
if (!sel || opts.compare()) return
selection.select(sel)
opts.remember(agent(), sel, next)
})
}
createEffect(() => {
if (!pending || !opts.hydrated()) return
// Initial host preferences may arrive after opening, but never replace an in-progress choice.
pending = false
const preferred = opts.preferred()
if (!preferred) return
selection.select({ providerID: preferred.providerID, modelID: preferred.modelID })
setVariant(preferred.variant)
})
return {
selection,
model,
agent,
variant,
variants,
effectiveVariant,
selectAgent,
selectModel,
selectVariant,
saved: () => ({ agent: agent(), model: selection.choice(), variant: variant() }),
}
}
@@ -51,18 +51,30 @@ export function createProjectRegistry(opts: { persisted: PersistedProjectTabs; a
/** The store of the project whose state is currently applied. */
const active = (): ProjectStore => ensure(opts.activeId())
const all = (): ProjectStore[] => [...stores.values()]
const scopes = () => {
version()
return all().flatMap((store) => [
...store.tabs.ids(),
...store
.managedSessions()
.filter((item) => item.worktreeId)
.map((item) => item.id),
])
}
/** Drop stores for projects that left the catalog (keeps "single" for legacy). */
const prune = (ids: Set<string>): void => {
const size = stores.size
for (const id of [...stores.keys()]) {
if (id === "single") continue
if (!ids.has(id)) stores.delete(id)
}
if (size !== stores.size) bump((n) => n + 1)
}
// Materialize the legacy bucket eagerly so migration works regardless of
// which project is ensured first.
if (opts.persisted.localSessionIDs?.length) ensure("single")
return { ensure, active, all, prune, version }
return { ensure, active, all, prune, version, scopes }
}
@@ -67,6 +67,7 @@ export const LocalTabsProvider: ParentComponent = (props) => {
const pending = () => `${PENDING_TAB_PREFIX}${crypto.randomUUID()}`
const init = restoreTabs(saved?.sidebarSessionTabIDs, saved?.sidebarActiveSessionTabID, pending)
const [ids, setIds] = createSignal(init.ids)
onCleanup(session.trackScopes(ids))
const [active, setActive] = createSignal(init.active)
const [cloud, setCloud] = createSignal<string>()
const fresh = new Set<string>()
@@ -8,6 +8,7 @@ export function resolveModelSelection(input: {
organizationId?: string | null
defaults?: Record<string, string>
session?: ModelSelection | null
preferred?: ModelSelection | null
override?: ModelSelection | null
mode?: ModelSelection | null
global?: ModelSelection | null
@@ -20,7 +21,11 @@ export function resolveModelSelection(input: {
return isModelValid(input.providers, input.connected, selection) ? selection : null
}
const preference =
validate(input.session) ?? validate(input.override) ?? validate(input.mode) ?? validate(input.global)
validate(input.session) ??
validate(input.preferred) ??
validate(input.override) ??
validate(input.mode) ??
validate(input.global)
if (preference) return preference
if (pending) return null
if (input.organizationId) {
@@ -0,0 +1,107 @@
import { batch, createEffect, createSignal, on, type Accessor } from "solid-js"
import type { ModelSelection, WebviewMessage } from "../types/messages"
import { DEFAULT_VARIANT, sessionVariantKeys, variantKey } from "./session-variant-store"
interface Store {
modelSelections: Record<string, ModelSelection | null>
sessionOverrides: Record<string, ModelSelection>
agentSelections: Record<string, string>
variantSelections: Record<string, string>
}
export function createModelPreferences(options: {
store: Store
model: (scope: "modelSelections" | "sessionOverrides", id: string, model: ModelSelection) => void
set: (key: string, variant: string) => void
clear: (update: (store: Store) => void) => void
scopes: () => (string | undefined)[]
initialized: (id: string) => boolean
selected: (id: string) => ModelSelection | null
defaults: (agent: string) => ModelSelection | null | undefined
agent: (id: string) => string
variant: (id: string, model: ModelSelection) => string | undefined
recent: (model: ModelSelection) => void
update: (agent: string, model: ModelSelection, variant: string) => void
post: (message: WebviewMessage) => void
}) {
const inventories = new Set<Accessor<readonly string[]>>()
const [version, setVersion] = createSignal(0)
const scopes = () => {
version()
return new Set(
[...options.scopes(), ...[...inventories].flatMap((ids) => [...ids()])].filter((id): id is string => !!id),
)
}
let previous = new Set<string>()
const sync = (ids: Set<string>) => {
for (const id of previous) if (/^(?:sidebar-)?pending:/.test(id) && !ids.has(id)) forget(id)
previous = ids
return ids
}
createEffect(on(scopes, sync))
function track(ids: Accessor<readonly string[]>) {
inventories.add(ids)
setVersion((value) => value + 1)
return () => {
inventories.delete(ids)
setVersion((value) => value + 1)
}
}
function forget(id: string) {
options.clear((store) => {
delete store.agentSelections[id]
delete store.sessionOverrides[id]
for (const key of sessionVariantKeys(store.variantSelections, id)) delete store.variantSelections[key]
})
}
function pin(id: string, freeze = false) {
if (!options.store.sessionOverrides[id] && !options.initialized(id)) return
const model = options.store.sessionOverrides[id] ?? options.defaults(options.agent(id)) ?? options.selected(id)
if (!model) return
const key = variantKey(model, options.agent(id), id)
// Freeze this draft's displayed Default before another scope changes shared preferences.
// Otherwise leave unset effort available for mode defaults, rather than inventing a choice.
const value = options.variant(id, model) ?? (freeze ? DEFAULT_VARIANT : undefined)
if (options.store.variantSelections[key] === undefined && value !== undefined) options.set(key, value)
// Copy inherited models so updates to a mode's store cannot mutate the session.
if (!options.store.sessionOverrides[id]) options.model("sessionOverrides", id, { ...model })
}
function retain() {
for (const id of sync(scopes())) pin(id, true)
}
function apply(agent: string, model: ModelSelection, id?: string) {
if (id) {
if (!/^(?:sidebar-)?pending:/.test(id)) options.recent(model)
options.model("sessionOverrides", id, model)
return
}
retain()
options.model("modelSelections", agent, model)
}
function remember(agent: string, model: ModelSelection, variant = "") {
batch(() => {
// New-session defaults must not change other open sessions or drafts.
retain()
options.recent(model)
options.model("modelSelections", agent, { ...model })
options.set(variantKey(model, agent), variant)
options.update(agent, model, variant)
})
options.post({
type: "persistModelSelection",
agent,
providerID: model.providerID,
modelID: model.modelID,
variant,
})
options.post({ type: "persistVariant", key: variantKey(model, agent), value: variant })
}
return { apply, pin, remember, track, forget }
}
@@ -17,6 +17,7 @@ export interface ModelStore {
agentSelections: Record<string, string>
recentModels: ModelSelection[]
userSetAgents?: Record<string, boolean>
preferred?: ModelSelection
}
export interface ResolveEnv {
@@ -36,6 +37,7 @@ function resolveModel(
override?: ModelSelection | null,
recents?: ModelSelection[],
session?: ModelSelection,
preferred?: ModelSelection,
): ModelSelection | null {
return resolveModelSelection({
providers: env.providers,
@@ -44,6 +46,7 @@ function resolveModel(
organizationId: env.organizationId,
defaults: env.defaults,
session,
preferred: preferred && { providerID: preferred.providerID, modelID: preferred.modelID },
override,
mode: env.getModeModel(agentName),
global: env.getGlobalModel(),
@@ -85,6 +88,7 @@ export function getSelected(
override,
store.recentModels,
sessionID ? store.sessionOverrides[sessionID] : undefined,
store.preferred,
)
}
@@ -95,11 +99,8 @@ export function getAgentModel(
agentName: string,
userSet = store.userSetAgents?.[agentName] === true,
): ModelSelection | null {
const override =
(env.getModeModel(agentName) && userSet) || (env.organizationId && !userSet)
? null
: store.modelSelections[agentName]
return resolveModel(env, agentName, override, store.recentModels)
const override = env.organizationId && !userSet ? null : store.modelSelections[agentName]
return resolveModel(env, agentName, override, store.recentModels, undefined, store.preferred)
}
export interface ApplyResult {
@@ -0,0 +1,55 @@
import { createComputed, on, onCleanup, type Accessor } from "solid-js"
export function createPreferenceLoader(opts: {
ready: Accessor<boolean>
connected: Accessor<boolean>
request: () => void
}): () => void {
let timer: ReturnType<typeof setTimeout> | undefined
let attempts = 0
let disposed = false
function cancel() {
clearTimeout(timer)
timer = undefined
}
function schedule() {
if (disposed || opts.ready() || !opts.connected()) return
timer = setTimeout(() => {
timer = undefined
if (disposed || opts.ready() || !opts.connected()) return
if (attempts === 4) {
// Exhaustion is not hydration: a late saved preference must still apply.
console.warn("[Kilo New] Model preferences did not load after 4 attempts; waiting for a later retry")
return
}
attempts++
opts.request()
schedule()
}, 3000)
}
function retry() {
if (disposed || opts.ready()) return
cancel()
attempts = 1
opts.request()
schedule()
}
onCleanup(() => {
disposed = true
cancel()
})
createComputed(
on([opts.ready, opts.connected], ([ready, connected], previous) => {
cancel()
// The host may already know the disk path before the backend connects.
if (!ready && (connected || !previous)) retry()
}),
)
return retry
}
@@ -112,6 +112,10 @@ export interface SessionContextValue {
selected: (sessionID?: string) => ModelSelection | null
modelForAgent: (agent: string) => ModelSelection | null
selectModel: (providerID: string, modelID: string, sessionID?: string) => void
preferredSelection: Accessor<(ModelSelection & { variant?: string }) | undefined>
preferencesReady: Accessor<boolean>
rememberSelection: (agent: string, model: ModelSelection, variant?: string) => void
trackScopes: (ids: Accessor<readonly string[]>) => () => void
// Cost and context usage for the current session
costBreakdown: Accessor<Array<{ label: string; cost: number }>>
@@ -147,6 +151,7 @@ export interface SessionContextValue {
variantList: (sessionID?: string) => string[]
currentVariant: (sessionID?: string) => string | undefined
variantForAgent: (agent: string, model: ModelSelection | null) => string | undefined
variantPreference: (agent: string, model: ModelSelection | null) => string | undefined
selectVariant: (value: string | undefined, sessionID?: string) => void
// Model favorites
@@ -40,11 +40,12 @@ export function getVariant(
agent: string,
session?: string,
configured?: string,
preferred?: string,
) {
if (variants.length === 0) return undefined
const scoped = session ? store[variantKey(sel, agent, session)] : undefined
const preset = configured && variants.includes(configured) ? configured : undefined
const stored = scoped ?? preset ?? store[variantKey(sel, agent)] ?? store[legacyVariantKey(sel)]
const stored = scoped ?? preferred ?? store[variantKey(sel, agent)] ?? store[legacyVariantKey(sel)] ?? preset
if (stored === undefined || stored === DEFAULT_VARIANT) return undefined
return preserveVariant(stored, variants)
}
@@ -55,9 +56,10 @@ export function getAgentVariant(
model: { variants?: Record<string, unknown> } | undefined,
agent: string,
configured?: string,
preferred?: string,
) {
if (!model?.variants) return undefined
return getVariant(store, sel, Object.keys(model.variants), agent, undefined, configured)
return getVariant(store, sel, Object.keys(model.variants), agent, undefined, configured, preferred)
}
/**
@@ -1,6 +1,13 @@
import type { Accessor } from "solid-js"
import type { AgentConfig, ExtensionMessage, ModelSelection } from "../types/messages"
import { DEFAULT_VARIANT, getAgentVariant, getVariant, preserveVariant, variantKey } from "./session-variant-store"
import {
DEFAULT_VARIANT,
getAgentVariant,
getVariant,
legacyVariantKey,
preserveVariant,
variantKey,
} from "./session-variant-store"
interface Model {
variants?: Record<string, unknown>
@@ -18,6 +25,8 @@ interface Options {
find: (selection: ModelSelection) => Model | undefined
post: (message: Message) => void
listen: (handler: (message: ExtensionMessage) => void) => () => void
preferred?: Accessor<(ModelSelection & { variant?: string }) | undefined>
remember: (agent: string, model: ModelSelection, variant: string) => void
}
export function createSessionVariants(options: Options) {
@@ -33,9 +42,22 @@ export function createSessionVariants(options: Options) {
return config.variant ?? undefined
}
const preferred = (selection: ModelSelection) => {
const value = options.preferred?.()
if (value?.providerID !== selection.providerID || value.modelID !== selection.modelID) return undefined
return value.variant ?? DEFAULT_VARIANT
}
const agent = (name: string, selection: ModelSelection | null) => {
if (!selection) return undefined
return getAgentVariant(options.selections(), selection, options.find(selection), name, configured(name, selection))
return getAgentVariant(
options.selections(),
selection,
options.find(selection),
name,
configured(name, selection),
preferred(selection),
)
}
const current = (sessionID?: string) => {
@@ -45,12 +67,33 @@ export function createSessionVariants(options: Options) {
const variants = list(sid)
if (variants.length === 0) return undefined
const name = options.agent(sid)
return getVariant(options.selections(), selection, variants, name, sid, configured(name, selection))
return getVariant(
options.selections(),
selection,
variants,
name,
sid,
configured(name, selection),
preferred(selection),
)
}
const request = (sessionID?: string) =>
current(sessionID) ?? (list(sessionID).length > 0 ? DEFAULT_VARIANT : undefined)
const saved = (selection: ModelSelection, name: string, sessionID?: string) =>
(sessionID ? options.selections()[variantKey(selection, name, sessionID)] : undefined) ??
preferred(selection) ??
options.selections()[variantKey(selection, name)] ??
options.selections()[legacyVariantKey(selection)] ??
configured(name, selection)
const choice = (sessionID?: string) => {
const id = sessionID ?? options.session()
const model = options.selected(id)
return model ? saved(model, options.agent(id), id) : undefined
}
const select = (value: string | undefined, sessionID?: string) => {
const sid = sessionID ?? options.session()
const selection = options.selected(sid)
@@ -58,16 +101,16 @@ export function createSessionVariants(options: Options) {
const key = variantKey(selection, options.agent(sid), sid)
const next = value ?? DEFAULT_VARIANT
options.set(key, next)
if (!sid) options.post({ type: "persistVariant", key, value: next })
if (!sid || /^(?:sidebar-)?pending:/.test(sid)) {
options.remember(options.agent(sid), selection, next)
}
}
const carry = (selection: ModelSelection, value: string | undefined, name: string, sessionID?: string) => {
const list = Object.keys(options.find(selection)?.variants ?? {})
if (list.length === 0) return
// An absent value means the model default, not an explicit user choice.
// Do not write a default sentinel here because it would shadow a cached
// agent-level variant when this selection is resolved for a new session.
const next = preserveVariant(value, list)
// Undefined leaves the target's effort intact; an explicit Default must be carried.
const next = value === DEFAULT_VARIANT ? DEFAULT_VARIANT : preserveVariant(value, list)
if (next === undefined) return
const key = variantKey(selection, name, sessionID)
options.set(key, next)
@@ -86,5 +129,5 @@ export function createSessionVariants(options: Options) {
return unsub
}
return { carry, list, agent, current, request, select, load }
return { carry, list, agent, current, request, saved, choice, select, load }
}
@@ -87,7 +87,7 @@ import { isolate, mergeOptimisticPart, mergeOptimisticParts, mergeParts } from "
import { mergeMessages, sameReconcileShape } from "./session-merge"
import { createFrameQueue, streamMessage } from "./frame-queue"
import { state as todoState } from "./todo-revert"
import { sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store"
import { preserveVariant, sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store"
import { createSessionVariants } from "./session-variants"
import { KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "../../../src/shared/provider-model"
import { type ReviewMessageData } from "../../../src/shared/review-comments"
@@ -101,6 +101,8 @@ import { clearIfOn, createCloudPrune } from "./session-cloud-prune"
import { isSameSessionTree } from "./model-usage"
import { createDraftAgentSeed, resolvePromptAgent } from "./session-agent"
import { createModelSelector } from "./session-model-selector"
import { createModelPreferences } from "./session-model-preferences"
import { createPreferenceLoader } from "./session-preference-loader"
import { activities, type Activity } from "../utils/session-activity"
import { active as activeTiming, hold, type Timing } from "./session-timing"
import type { SessionContextValue } from "./session-types"
@@ -249,6 +251,9 @@ export const SessionProvider: ParentComponent = (props) => {
// Tracks whether the user has explicitly set a model override per agent (to
// prevent the default-sync effect from overwriting it).
const [userSetAgents, setUserSetAgents] = createSignal<Record<string, boolean>>({})
const [preferredSelection, setPreferredSelection] = createSignal<ModelSelection & { variant?: string }>()
const [preferencesReady, setPreferencesReady] = createSignal(false)
let remembered = false
// Agents (modes) loaded from the CLI backend
const [agents, setAgents] = createSignal<AgentInfo[]>([])
@@ -472,25 +477,21 @@ export const SessionProvider: ParentComponent = (props) => {
agentSelections: store.agentSelections,
recentModels: store.recentModels,
userSetAgents: userSetAgents(),
preferred: preferredSelection(),
}
}
function resolveModel(agentName: string): ModelSelection | null {
return resolveModelSelection({
// Keep automatic defaults in sync until the user chooses a model.
createEffect(() => {
const agentName = selectedAgentName()
if (userSetAgents()[agentName]) return
const sel = resolveModelSelection({
...environment(),
mode: getModeModel(agentName),
global: getGlobalModel(),
recent: store.recentModels,
})
}
// Keep model selection in sync with provider/mode default until the user
// explicitly overrides it.
createEffect(() => {
const agentName = selectedAgentName()
if (userSetAgents()[agentName]) return
const sel = resolveModel(agentName)
setStore("modelSelections", agentName, sel)
setStore("modelSelections", agentName, sel && { ...sel })
})
const currentSelected = createMemo<ModelSelection | null>(() =>
@@ -519,24 +520,30 @@ export const SessionProvider: ParentComponent = (props) => {
vscode.postMessage({ type: "recordModelUsage", providerID, modelID })
}
function applyModel(agentName: string, selection: ModelSelection, sessionID?: string) {
pushRecent(selection)
if (sessionID) {
setStore("sessionOverrides", sessionID, selection)
return
}
// Always remember the per-mode model choice so switching modes restores
// the last-used model (mirrors CLI TUI's model.json behavior).
setUserSetAgents((prev) => ({ ...prev, [agentName]: true }))
setStore("modelSelections", agentName, selection)
// Persist to model.json via the extension host
vscode.postMessage({
type: "persistModelSelection",
agent: agentName,
providerID: selection.providerID,
modelID: selection.modelID,
})
}
const memory = createModelPreferences({
store,
model: (scope, id, model) => setStore(scope, id, model),
set: (key, value) => setStore("variantSelections", key, value),
clear: (update) => setStore(produce((store) => update(store))),
scopes: () => [currentSessionID(), draftSessionID(), ...Object.keys(submissionMap)],
initialized: (id) => /^(?:sidebar-)?pending:/.test(id) || isSubmitting(id) || store.messages[id] !== undefined,
selected,
defaults: (agent) =>
preferredSelection() ??
(userSetAgents()[agent] ? store.modelSelections[agent] : undefined) ??
getModeModel(agent) ??
getGlobalModel(),
agent: agentForScope,
variant: (id, model) => variants.saved(model, agentForScope(id), id),
recent: pushRecent,
update: (agent, model, variant) => {
remembered = true
setUserSetAgents((prev) => ({ ...prev, [agent]: true }))
setPreferredSelection({ ...model, variant })
},
post: vscode.postMessage,
})
const rememberSelection = memory.remember
const variants = createSessionVariants({
selections: () => store.variantSelections,
@@ -548,6 +555,8 @@ export const SessionProvider: ParentComponent = (props) => {
find: provider.findModel,
post: vscode.postMessage,
listen: vscode.onMessage,
preferred: preferredSelection,
remember: rememberSelection,
})
const { carry: carryVariant, list: variantList, agent: variantForAgent, current: currentVariant } = variants
const selectVariant = variants.select
@@ -555,13 +564,25 @@ export const SessionProvider: ParentComponent = (props) => {
current: currentSessionID,
agent: agentForScope,
selected,
variant: currentVariant,
apply: applyModel,
variant: variants.choice,
apply: memory.apply,
set: (id, selection) => setStore("sessionOverrides", id, selection),
carry: carryVariant,
hide: hideErrors,
})
const selectModel = models.select
function selectModel(providerID: string, modelID: string, sessionID?: string) {
const id = sessionID ?? currentSessionID()
batch(() => {
models.select(providerID, modelID, id)
if (!id || /^(?:sidebar-)?pending:/.test(id)) {
const model = { providerID, modelID }
const agent = agentForScope(id)
const value = store.variantSelections[variantKey(model, agent, id)] ?? variantForAgent(agent, model)
const list = Object.keys(provider.findModel(model)?.variants ?? {})
rememberSelection(agent, model, value === "" ? "" : preserveVariant(value, list))
}
})
}
function selectKiloModel(modelID?: string, agent?: string) {
if (!modelID && !agent) return
@@ -616,24 +637,6 @@ export const SessionProvider: ParentComponent = (props) => {
})
}
function clearModeModelSelection(agentName: string) {
setUserSetAgents((prev) => {
const next = { ...prev }
delete next[agentName]
return next
})
setStore(
"modelSelections",
produce((selections) => {
delete selections[agentName]
}),
)
}
function shouldClearModeModelSelection(agentName: string) {
return getModeModel(agentName) !== null && userSetAgents()[agentName] === true
}
function clearHiddenErrors(ids: string[]) {
if (ids.length === 0) return
setHiddenErrors((prev) => {
@@ -745,6 +748,7 @@ export const SessionProvider: ParentComponent = (props) => {
if (message.type !== "extensionDataReady") return
unsubReady()
clearTimeout(fallback)
retryPreferences()
if (agents().length === 0) vscode.postMessage({ type: "requestAgents" })
if (Object.keys(mcpStatus()).length === 0) vscode.postMessage({ type: "requestMcpStatus" })
})
@@ -764,15 +768,26 @@ export const SessionProvider: ParentComponent = (props) => {
const unsubSelections = vscode.onMessage((message: ExtensionMessage) => {
if (message.type !== "modelSelectionsLoaded") return
batch(() => {
setStore("modelSelections", reconcile(message.selections))
const local =
!preferencesReady() && remembered
? Object.fromEntries(Object.entries(store.modelSelections).filter(([name]) => userSetAgents()[name]))
: {}
const selections = { ...message.selections, ...local }
setStore("modelSelections", reconcile(selections))
const flags: Record<string, boolean> = {}
for (const name of Object.keys(message.selections)) {
for (const name of Object.keys(selections)) {
flags[name] = true
}
setUserSetAgents(flags)
if (preferencesReady() || !remembered) setPreferredSelection(message.preferred)
setPreferencesReady(true)
})
})
vscode.postMessage({ type: "requestModelSelections" })
const retryPreferences = createPreferenceLoader({
ready: preferencesReady,
connected: server.isConnected,
request: () => vscode.postMessage({ type: "requestModelSelections" }),
})
onCleanup(unsubSelections)
// Load persisted recent models from extension globalState
@@ -1112,12 +1127,6 @@ export const SessionProvider: ParentComponent = (props) => {
.filter((message) => !ids.has(message.id))
.map((message) => ({ ...message, sessionID: session.id }))
setStore("messages", session.id, [...current, ...promoted])
setStore(
"messages",
produce((messages) => {
delete messages[draftID]
}),
)
const pending = pendingOptimistic.get(draftID)
if (pending) {
@@ -1153,6 +1162,13 @@ export const SessionProvider: ParentComponent = (props) => {
const pendingAgent = draftID ? store.agentSelections[draftID] : pendingAgentSelection()
const pendingModel = draftID ? store.sessionOverrides[draftID] : undefined
if (draftID) {
// Goal commands have no optimistic messages, but their empty draft cache must also be removed.
setStore(
"messages",
produce((messages) => {
delete messages[draftID]
}),
)
const entries = transferVariants(store.variantSelections, draftID, session.id)
for (const [key, value] of Object.entries(entries)) {
setStore("variantSelections", key, value)
@@ -1160,24 +1176,7 @@ export const SessionProvider: ParentComponent = (props) => {
}
if (pendingAgent) setStore("agentSelections", session.id, pendingAgent)
if (pendingModel) setStore("sessionOverrides", session.id, pendingModel)
setStore(
"agentSelections",
produce((agents) => {
delete agents[draftID]
}),
)
setStore(
"sessionOverrides",
produce((models) => {
delete models[draftID]
}),
)
setStore(
"variantSelections",
produce((variants) => {
for (const key of sessionVariantKeys(variants, draftID)) delete variants[key]
}),
)
memory.forget(draftID)
agentDrafts.promote(draftID)
} else if (pendingAgent && !store.agentSelections[session.id]) {
setStore("agentSelections", session.id, pendingAgent)
@@ -2122,30 +2121,16 @@ export const SessionProvider: ParentComponent = (props) => {
function selectAgent(name: string, sessionID?: string) {
const id = sessionID ?? currentSessionID()
if (id) {
memory.pin(id)
setStore("agentSelections", id, name)
// Clear per-session model override so the new mode's configured/default
// model takes effect instead of the previous mode's override.
setStore(
"sessionOverrides",
produce((overrides) => {
delete overrides[id]
}),
)
if (shouldClearModeModelSelection(name)) {
clearModeModelSelection(name)
}
} else {
setPendingAgentSelection(name)
if (shouldClearModeModelSelection(name)) {
clearModeModelSelection(name)
return
}
// When switching mode, initialize model for the new mode if the user
// hasn't explicitly set one for it
if (!userSetAgents()[name] && !store.modelSelections[name]) {
setStore("modelSelections", name, resolveModel(name))
}
return
}
const agent = selectedAgentName()
const model = store.modelSelections[agent]
if (!preferredSelection() && userSetAgents()[agent] && model) {
setPreferredSelection({ ...model, variant: variants.saved(model, agent) })
}
setPendingAgentSelection(name)
}
/** Create an optimistic user message + parts in the store so the UI updates instantly. */
@@ -2303,18 +2288,21 @@ export const SessionProvider: ParentComponent = (props) => {
if (control) return null
if (overrides?.model) return parseModelString(overrides.model)
const scope = draftID ?? sid
const model = overrides?.agent
? modelForAgent(overrides.agent)
: scope
? selected(scope)
: getSelected(preferences(), environment(), undefined, pendingAgentSelection() ?? defaultAgent())
const model = scope
? selected(scope)
: getSelected(preferences(), environment(), undefined, pendingAgentSelection() ?? defaultAgent())
return model ?? (providerID && modelID ? { providerID, modelID } : null)
})()
if (!control && !available(effectiveSelection)) return false
const effectiveDraftID = !sid && !draftID ? crypto.randomUUID() : draftID
const scope = effectiveDraftID ?? sid
if (!sid && !draftID && effectiveDraftID) agentDrafts.seed(effectiveDraftID)
if (!sid && !draftID && effectiveDraftID) {
agentDrafts.seed(effectiveDraftID)
// This UUID is a known-new draft, not unopened history. Initialize it so a mode-only
// command retains the outgoing model/effort together instead of mixing two modes.
setStore("messages", effectiveDraftID, [])
}
if (effectiveSelection) {
if (overrides?.agent) {
@@ -2323,7 +2311,7 @@ export const SessionProvider: ParentComponent = (props) => {
if (overrides?.model) {
selectModel(effectiveSelection.providerID, effectiveSelection.modelID, scope)
}
if (overrides?.variant) {
if (overrides?.variant !== undefined) {
selectVariant(overrides.variant, scope)
}
recordModelUsage(effectiveSelection.providerID, effectiveSelection.modelID)
@@ -2991,6 +2979,10 @@ export const SessionProvider: ParentComponent = (props) => {
selected,
modelForAgent,
selectModel,
preferredSelection,
preferencesReady,
rememberSelection,
trackScopes: memory.track,
costBreakdown,
contextUsage,
modelUsage,
@@ -3032,6 +3024,7 @@ export const SessionProvider: ParentComponent = (props) => {
variantList,
currentVariant,
variantForAgent,
variantPreference: (agent, model) => (model ? variants.saved(model, agent) : undefined),
selectVariant,
revert,
revertedCount,
@@ -237,6 +237,10 @@ export function mockSessionValue(overrides?: {
selected: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }),
modelForAgent: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }),
selectModel: noop,
preferredSelection: () => undefined,
preferencesReady: () => true,
rememberSelection: noop,
trackScopes: () => noop,
costBreakdown: () => [],
contextUsage: () => undefined,
modelUsage: () => undefined,
@@ -265,6 +269,7 @@ export function mockSessionValue(overrides?: {
variantList: () => [],
currentVariant: () => undefined,
variantForAgent: () => undefined,
variantPreference: () => undefined,
selectVariant: noop,
sendMessage: () => true,
sendCommand: () => true,
@@ -1120,10 +1120,11 @@ export interface FavoritesLoadedMessage {
favorites: ModelSelection[]
}
// Per-mode model selections loaded from model.json (extension → webview)
// Preferred and per-mode model selections loaded from persisted state (extension → webview)
export interface ModelSelectionsLoadedMessage {
type: "modelSelectionsLoaded"
selections: Record<string, ModelSelection>
preferred?: ModelSelection & { variant?: string }
}
export interface AgentManagerBranchesMessage {
@@ -1484,12 +1484,13 @@ export interface RequestFavoritesMessage {
type: "requestFavorites"
}
// Per-mode model selection persistence (webview → extension)
// Explicit preferred and per-mode model selection persistence (webview → extension)
export interface PersistModelSelectionRequest {
type: "persistModelSelection"
agent: string
providerID: string
modelID: string
variant?: string
}
export interface RequestModelSelectionsMessage {