mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 01:51:21 +08:00
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:
@@ -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()
|
||||
}
|
||||
+439
-16
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user