fix(vscode): preserve effort intent and live session defaults

This commit is contained in:
webreflection
2026-09-16 11:06:53 +02:00
parent dd2f2f9a97
commit 50f7d01ad7
17 changed files with 778 additions and 192 deletions
@@ -0,0 +1,169 @@
import assert from "node:assert/strict"
import { jest, spyOn } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createPreferenceLoader } from "../../webview-ui/src/context/session-preference-loader"
function setup(online = true, loaded = false) {
return createRoot((dispose) => {
const [ready, hydrate] = createSignal(loaded)
const [connected, connect] = createSignal(online)
const sent: number[] = []
const retry = createPreferenceLoader({ ready, connected, request: () => sent.push(Date.now()) })
return { dispose, ready, hydrate, connect, sent, retry }
})
}
jest.useFakeTimers()
const warning = spyOn(console, "warn").mockImplementation(() => undefined)
const cases: Record<string, () => void> = {
exhaustion() {
const state = setup()
const start = Date.now()
assert.deepEqual(state.sent, [start])
jest.advanceTimersByTime(2999)
assert.equal(state.sent.length, 1)
jest.advanceTimersByTime(6001)
assert.deepEqual(state.sent, [start, start + 3000, start + 6000, start + 9000])
assert.equal(warning.mock.calls.length, 0, "the final request gets a response window")
jest.advanceTimersByTime(3000)
assert.equal(warning.mock.calls.length, 1)
assert.match(String(warning.mock.calls.at(0)?.at(0)), /\[Kilo New\].*preferences.*4 attempts/)
assert.equal(state.ready(), false, "exhaustion must not synthesize successful empty preferences")
assert.equal(jest.getTimerCount(), 0)
jest.advanceTimersByTime(60000)
assert.equal(state.sent.length, 4)
assert.equal(warning.mock.calls.length, 1)
state.hydrate(true)
assert.equal(state.ready(), true, "genuine late hydration remains authoritative")
state.retry()
assert.equal(state.sent.length, 4)
state.dispose()
},
offline() {
const state = setup(false)
assert.equal(state.sent.length, 1, "request the host's cached disk path even while offline")
assert.equal(jest.getTimerCount(), 0)
jest.advanceTimersByTime(60000)
assert.equal(state.sent.length, 1)
assert.equal(warning.mock.calls.length, 0)
state.retry()
assert.equal(state.sent.length, 2, "extensionDataReady may also arrive before connected state")
assert.equal(jest.getTimerCount(), 0)
state.connect(true)
assert.equal(state.sent.length, 3)
assert.equal(jest.getTimerCount(), 1)
jest.advanceTimersByTime(3000)
assert.equal(state.sent.length, 4)
state.dispose()
},
reconnect() {
const state = setup()
jest.advanceTimersByTime(3000)
assert.equal(state.sent.length, 2)
state.connect(false)
assert.equal(jest.getTimerCount(), 0)
jest.advanceTimersByTime(60000)
assert.equal(state.sent.length, 2)
assert.equal(warning.mock.calls.length, 0)
state.connect(true)
assert.equal(state.sent.length, 3)
jest.advanceTimersByTime(12000)
assert.equal(state.sent.length, 6)
assert.equal(warning.mock.calls.length, 1)
state.connect(true)
assert.equal(jest.getTimerCount(), 0, "unchanged connection does not restart an exhausted cycle")
state.connect(false)
state.connect(true)
assert.equal(state.sent.length, 7, "reconnect restarts even after exhaustion")
jest.advanceTimersByTime(12000)
assert.equal(state.sent.length, 10)
assert.equal(warning.mock.calls.length, 2, "warn once per exhausted connection cycle")
assert.equal(state.ready(), false)
state.dispose()
},
retry() {
const state = setup()
jest.advanceTimersByTime(1000)
state.retry()
assert.equal(state.sent.length, 2)
assert.equal(jest.getTimerCount(), 1, "extensionDataReady replaces, not duplicates, the timer")
jest.advanceTimersByTime(2000)
assert.equal(state.sent.length, 2, "the original timer was cancelled")
jest.advanceTimersByTime(1000)
assert.equal(state.sent.length, 3)
jest.advanceTimersByTime(9000)
assert.equal(state.sent.length, 5)
assert.equal(warning.mock.calls.length, 1)
state.retry()
assert.equal(state.sent.length, 6, "extensionDataReady can recover an exhausted cycle")
jest.advanceTimersByTime(12000)
assert.equal(state.sent.length, 9)
assert.equal(warning.mock.calls.length, 2)
assert.equal(jest.getTimerCount(), 0)
state.dispose()
},
ready() {
const state = setup()
jest.advanceTimersByTime(9000)
state.hydrate(true)
assert.equal(jest.getTimerCount(), 0, "readiness cancels the final response window")
state.connect(false)
state.connect(true)
state.retry()
jest.advanceTimersByTime(60000)
assert.equal(state.sent.length, 4)
assert.equal(warning.mock.calls.length, 0)
state.dispose()
},
cleanup() {
const state = setup()
assert.equal(jest.getTimerCount(), 1)
state.dispose()
assert.equal(jest.getTimerCount(), 0)
state.retry()
state.connect(false)
state.connect(true)
jest.advanceTimersByTime(60000)
assert.equal(state.sent.length, 1, "disposed owners cannot restart requests")
assert.equal(warning.mock.calls.length, 0)
assert.equal(state.ready(), false)
},
loaded() {
const state = setup(false, true)
state.connect(true)
state.retry()
assert.equal(state.sent.length, 0)
assert.equal(jest.getTimerCount(), 0)
state.dispose()
},
synchronous() {
createRoot((dispose) => {
const [ready, hydrate] = createSignal(false)
let sent = 0
const retry = createPreferenceLoader({
ready,
connected: () => true,
request: () => {
sent++
hydrate(true)
},
})
assert.equal(ready(), true)
assert.equal(jest.getTimerCount(), 0, "a cached synchronous response does not leave a timer")
retry()
assert.equal(sent, 1)
dispose()
})
},
}
try {
const name = process.argv.at(2)
assert.ok(name && cases[name], `Unknown preference loader case: ${name}`)
cases[name]()
assert.equal(jest.getTimerCount(), 0, "each case disposes all retry timers")
} finally {
warning.mockRestore()
jest.useRealTimers()
}
@@ -58,6 +58,8 @@ const { LanguageContext } = await import("../../webview-ui/src/context/language"
const { NotificationsProvider } = await import("../../webview-ui/src/context/notifications")
const { ProviderProvider } = await import("../../webview-ui/src/context/provider")
const { SessionProvider, useSession, useSessionVisibility } = await import("../../webview-ui/src/context/session")
const { LocalTabsProvider, useLocalTabs } = await import("../../webview-ui/src/context/local-tabs")
const { createProjectRegistry } = await import("../../webview-ui/agent-manager/project/registry")
const { initialMessage } = await import("../../webview-ui/agent-manager/initial-message")
const { useBaseUpdate } = await import("../../webview-ui/agent-manager/update-from-base")
const { post } = await import("../../webview-ui/src/utils/webview-message")
@@ -114,6 +116,12 @@ const [active, setActive] = createSignal("task-child")
const [review, setReview] = createSignal(false)
const [sharing, setSharing] = createSignal(false)
const peer = { value: undefined as ReturnType<typeof useSession> | undefined }
const [tabbed, setTabbed] = createSignal(false)
const tabs = { value: undefined as ReturnType<typeof useLocalTabs> | undefined }
const Tabs = () => {
tabs.value = useLocalTabs()
return null
}
const Peer = () => {
peer.value = useSession()
return null
@@ -143,6 +151,11 @@ const Probe = () => {
} as Parameters<typeof renderTab>[1]
return (
<DragDropProvider>
<Show when={tabbed()}>
<LocalTabsProvider>
<Tabs />
</LocalTabsProvider>
</Show>
<Show when={sharing()}>
<SessionProvider>
<Peer />
@@ -699,6 +712,7 @@ try {
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" })
@@ -778,13 +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()
const preserved = value.submission(scope)
assert.deepEqual(value.submission(scope), { model: personal, variant: "high", agent: "code" })
assert.equal(
value.sendCommand(
"review-test",
@@ -804,7 +819,7 @@ try {
assert.equal(request.sessionID, scope)
assert.equal(request.agent, "ask")
assert.equal(request.modelID, personal.modelID)
assert.equal(request.variant, preserved.variant)
assert.equal(request.variant, "high")
assert.equal(value.selectedAgent(scope), "ask")
choice(value.selected(scope), personal)
}
@@ -816,10 +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()
const inheritedCommand = value.submission("ses_command-cached")
assert.deepEqual(value.submission("ses_command-cached"), { model: first, variant: "high", agent: "code" })
assert.equal(
value.sendCommand(
"review-test",
@@ -836,9 +856,9 @@ try {
)
const configured = requests().at(-1)
assert(configured?.type === "sendCommand")
assert.equal(configured.modelID, inheritedCommand.model?.modelID)
assert.equal(configured.variant, inheritedCommand.variant)
assert.deepEqual(value.submission("ses_command-cached"), { ...inheritedCommand, agent: "ask" })
assert.equal(configured.modelID, first.modelID)
assert.equal(configured.variant, "high")
assert.deepEqual(value.submission("ses_command-cached"), { model: first, variant: "high", agent: "ask" })
assert.equal(
value.sendCommand(
@@ -864,9 +884,8 @@ try {
await emit({ type: "modelSelectionsLoaded", selections: { code: first, ask: recommended } })
value.setCurrentSessionID(undefined)
value.selectAgent("ask")
const pendingModel = value.selected()
assert(pendingModel)
const pendingVariant = value.variantForAgent("ask", pendingModel)
choice(value.selected(), first)
assert.equal(value.variantForAgent("ask", first), "high")
value.setCurrentSessionID("selection")
assert.equal(
value.sendCommand(
@@ -887,12 +906,12 @@ try {
assert(pending.draftID)
assert.equal(pending.sessionID, undefined)
assert.equal(pending.agent, "ask")
assert.equal(pending.modelID, pendingModel.modelID)
assert.equal(pending.modelID, first.modelID)
assert.equal(pending.variant, "high")
assert.equal(value.selectedAgent(pending.draftID), "ask")
choice(value.selected(pending.draftID), pendingModel)
choice(value.selected(pending.draftID), first)
assert.equal(value.currentVariant(pending.draftID), "high")
assert.equal(value.variantForAgent("ask", pendingModel), pendingVariant)
assert.equal(value.variantForAgent("ask", first), "high")
const persisted = sent.length
assert.equal(
value.sendCommand(
@@ -920,7 +939,7 @@ 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"), pendingModel)
choice(value.modelForAgent("ask"), first)
assert.equal(
sent.slice(persisted).some((message) => message.type === "persistModelSelection"),
false,
@@ -965,7 +984,7 @@ try {
value.clearCurrentSession()
value.selectAgent("ask")
assert.equal(value.selectedAgent(), "ask")
choice(value.selected(), pendingModel)
choice(value.selected(), first)
const usage = JSON.stringify(value.modelUsageHistory())
const recent = JSON.stringify(value.recentModels())
const start = sent.length
@@ -985,7 +1004,7 @@ try {
await emit({ type: "sessionCreated", session: info("ses_goal-draft"), draftID: command.draftID })
assert.equal(value.currentSessionID(), "ses_goal-draft")
assert.equal(value.selectedAgent(), "ask")
choice(value.selected(), pendingModel)
choice(value.selected(), first)
await emit({ type: "sessionCommandCompleted", messageID: command.messageID })
assert.equal(value.submitting(), false)
assert.equal(JSON.stringify(value.modelUsageHistory()), usage)
@@ -2084,6 +2103,7 @@ try {
})
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")
@@ -2187,6 +2207,196 @@ try {
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)
}
assert.deepEqual(failures, [])
} finally {
const before = state("background")
@@ -7,14 +7,6 @@ const providerPath = join(__dirname, "..", "..", "src", "KiloProvider.ts")
const src = readFileSync(path, "utf8")
const provider = readFileSync(providerPath, "utf8")
function fragment(start: string, end: string) {
const from = src.indexOf(start)
const to = src.indexOf(end, from)
expect(from).toBeGreaterThanOrEqual(0)
expect(to).toBeGreaterThan(from)
return new Bun.Transpiler({ loader: "tsx" }).transformSync(src.slice(from, to))
}
describe("NewWorktreeDialog sandbox toggle", () => {
it("uses the persisted default and only sends explicit modal overrides", () => {
expect(src).toContain('vscode.postMessage({ type: "requestSandboxDefault", requestID: sandboxRequestID })')
@@ -55,7 +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, preserveVariant } from "./src/context/session-variant-store.ts"
import { DEFAULT_VARIANT } from "./src/context/session-variant-store.ts"
const solid = join(dirname(require.resolve("solid-js")), "solid.js")
plugin({
@@ -64,8 +56,8 @@ function check(code: string) {
build.onResolve({ filter: /^solid-js$/ }, () => ({ path: solid }))
},
})
const { batch, createComputed, createEffect, createMemo, createRoot, createSignal, onCleanup } = 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" }
@@ -97,42 +89,31 @@ function check(code: string) {
return { state, snapshot, refresh: (update) => refresh((current) => ({ ...current, ...update })), switchAgent, seen }
}
// Run the dialog's actual model/variant setup, handlers and persistence effect, without rendering unrelated UI.
// 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 [agent, setAgent] = createSignal(saved.agent ?? "code")
const [compareMode, setCompareMode] = createSignal(false)
const [modelAllocations, setModelAllocations] = createSignal(new Map())
const [sandbox] = createSignal(undefined)
const preferences = []
const provider = {
const state = createDialogPreferences({
saved,
agent: saved.agent ?? "code",
ready: () => snapshot().ready,
isModelValid: (value) => isModelValid(snapshot().providers, snapshot().connected, value),
findModel: (value) => snapshot().providers[value.providerID]?.models[value.modelID],
}
const session = {
modelForAgent: (name) => name === "code" ? snapshot().fallback : snapshot().alternate ?? null,
variantForAgent: (name) => snapshot().efforts?.[name],
preferredSelection: () => snapshot().preferred,
preferencesReady: () => snapshot().hydrated ?? true,
rememberSelection: (...args) => preferences.push(args),
}
let cached = {}
const vscode = { getState: () => cached, setState: (value) => { cached = value } }
${fragment("const preferred =", "const [versions")}
${fragment("const selection = createDialogModels({", "const [compareMode")}
${fragment("const [variant, setVariant] =", "const [sandbox, setSandbox] =")}
${fragment("const selectAgent =", "const cycle =")}
${fragment("// Variant list for the currently selected model", " createEffect(() => {\n if (!sandboxVisible())")}
${fragment(" createEffect(() => {\n const state = vscode.getState", "// Auto-persist images")}
const pick = ${src.match(/onSelect=\{(\(pid, mid\) => \{[\s\S]*?\n\s*\})\}/)?.[1]}
const choose = ${src.match(/<ThinkingSelectorBase[\s\S]*?onSelect=\{([^\n]*)\}/)?.[1]}
const clear = ${src.match(/<ThinkingSelectorBase[\s\S]*?onClear=\{([^\n]*)\}/)?.[1]}
const allocate = ${src.match(/<MultiModelSelector[^\n]*onChange=\{([^\n]*)\}/)?.[1]}
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 {
selection, model, variant, effectiveVariant, pick, choose, clear, selectAgent, setCompareMode, preferences, allocate,
cached: () => cached.advancedDialogSelections,
...state, setCompareMode, preferences,
pick: state.selectModel,
choose: state.selectVariant,
clear: () => state.selectVariant(DEFAULT_VARIANT),
cached: state.saved,
refresh: (update) => refresh((current) => ({ ...current, ...update })), dispose,
}
})
@@ -156,26 +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("const preferred = session.preferredSelection()")
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).not.toContain("selection.select(undefined)")
expect(src).toContain("selection.retain()")
expect(src).not.toContain("setModel(")
expect(src).toContain("selection.select(next)")
expect(src).toContain("selectVariant(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", () => {
@@ -208,6 +187,20 @@ describe("NewWorktreeDialog models", () => {
`)
})
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({}, {
@@ -229,6 +222,45 @@ describe("NewWorktreeDialog models", () => {
`)
})
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" })
@@ -401,7 +433,8 @@ describe("NewWorktreeDialog models", () => {
state.refresh({ fallback: x })
assert.deepEqual(state.model(), y)
state.setCompareMode(true)
state.allocate(setAllocationVariant(toggleModel(new Map(), "kilo", "x", "X"), "kilo", "x", "high"))
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")
@@ -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\)/,
)
})
@@ -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)
},
)
})
@@ -44,6 +44,25 @@ function setup(session?: string, configured?: string) {
}
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()
@@ -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
@@ -136,24 +136,23 @@ export const NewWorktreeDialog: Component<{
const cached = vscode.getState<Record<string, unknown>>()
const [prompt, setPrompt] = createSignal((cached?.advancedDialogPrompt as string) ?? "")
const saved = readDialogSelections(cached?.advancedDialogSelections)
const preferred = session.preferredSelection()
let pending = !session.preferencesReady()
if (preferred) {
saved.model = { providerID: preferred.providerID, modelID: preferred.modelID }
saved.variant = preferred.variant
}
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)
@@ -163,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>()
@@ -184,13 +182,6 @@ export const NewWorktreeDialog: Component<{
setEnhancing(false)
}
const selectAgent = (name: string) => {
pending = false
selection.retain()
setVariant(variant() ?? effectiveVariant() ?? (variants().length > 0 ? DEFAULT_VARIANT : undefined))
setAgent(name)
}
const cycle = (direction: 1 | -1) => {
cycleAgent({
agents: session.agents(),
@@ -206,43 +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())
// Catalog refreshes may temporarily hide a model or effort. Never rewrite the saved choice.
return preserveVariant(stored, list)
})
const selectVariant = (value: string | undefined) => {
pending = false
const next = value ?? DEFAULT_VARIANT
setVariant(next)
const sel = model()
if (!sel || compareMode()) return
selection.select(sel)
session.rememberSelection(agent(), sel, next)
}
createEffect(() => {
if (!pending || !session.preferencesReady()) return
// Initial host preferences may arrive after opening, but never replace an in-progress choice.
pending = false
const preferred = session.preferredSelection()
if (!preferred) return
selection.select({ providerID: preferred.providerID, modelID: preferred.modelID })
setVariant(preferred.variant)
})
createEffect(() => {
if (!sandboxVisible()) return
if (server.connectionState() !== "connected") {
@@ -317,9 +271,7 @@ export const NewWorktreeDialog: Component<{
vscode.setState({
...state,
advancedDialogSelections: {
agent: agent(),
model: selection.choice(),
variant: variant(),
...preferences.saved(),
sandbox: sandbox(),
},
})
@@ -841,17 +793,7 @@ export const NewWorktreeDialog: Component<{
<Show when={!compareMode()}>
<ModelSelectorBase
value={model()}
onSelect={(pid, mid) => {
if (!pid || !mid) return
pending = false
const current = variant() ?? effectiveVariant()
const next = { providerID: pid, modelID: mid }
const list = Object.keys(provider.findModel(next)?.variants ?? {})
const effort = preserveVariant(current, list) ?? DEFAULT_VARIANT
selection.select(next)
setVariant(effort)
session.rememberSelection(agent(), next, effort)
}}
onSelect={selectModel}
onPick={restorePrompt}
onCancel={restorePrompt}
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: {
@@ -36,3 +37,92 @@ export function createDialogModels(opts: {
}
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>()
@@ -1,6 +1,6 @@
import { batch } from "solid-js"
import { batch, createEffect, createSignal, on, type Accessor } from "solid-js"
import type { ModelSelection, WebviewMessage } from "../types/messages"
import { variantKey } from "./session-variant-store"
import { DEFAULT_VARIANT, sessionVariantKeys, variantKey } from "./session-variant-store"
interface Store {
modelSelections: Record<string, ModelSelection | null>
@@ -13,6 +13,7 @@ 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
@@ -23,20 +24,52 @@ export function createModelPreferences(options: {
update: (agent: string, model: ModelSelection, variant: string) => void
post: (message: WebviewMessage) => void
}) {
function pin(id: string) {
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)
const value = options.variant(id, model)
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() {
const ids = new Set([...options.scopes(), ...Object.keys(options.store.agentSelections)])
for (const id of ids) if (id) pin(id)
for (const id of sync(scopes())) pin(id, true)
}
function apply(agent: string, model: ModelSelection, id?: string) {
@@ -68,5 +101,5 @@ export function createModelPreferences(options: {
options.post({ type: "persistVariant", key: variantKey(model, agent), value: variant })
}
return { apply, pin, remember }
return { apply, pin, remember, track, forget }
}
@@ -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
}
@@ -115,6 +115,7 @@ export interface SessionContextValue {
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 }>>
@@ -150,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
@@ -86,8 +86,13 @@ export function createSessionVariants(options: Options) {
preferred(selection) ??
options.selections()[variantKey(selection, name)] ??
options.selections()[legacyVariantKey(selection)] ??
configured(name, selection) ??
DEFAULT_VARIANT
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()
@@ -104,9 +109,7 @@ export function createSessionVariants(options: Options) {
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.
// 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)
@@ -126,5 +129,5 @@ export function createSessionVariants(options: Options) {
return unsub
}
return { carry, list, agent, current, request, saved, select, load }
return { carry, list, agent, current, request, saved, choice, select, load }
}
@@ -101,6 +101,7 @@ 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"
@@ -522,8 +523,9 @@ export const SessionProvider: ParentComponent = (props) => {
store,
model: (scope, id, model) => setStore(scope, id, model),
set: (key, value) => setStore("variantSelections", key, value),
scopes: () => [...Object.keys(store.messages), currentSessionID(), draftSessionID()],
initialized: (id) => !store.sessions[id] || store.messages[id] !== undefined,
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() ??
@@ -561,7 +563,7 @@ export const SessionProvider: ParentComponent = (props) => {
current: currentSessionID,
agent: agentForScope,
selected,
variant: variants.request,
variant: variants.choice,
apply: memory.apply,
set: (id, selection) => setStore("sessionOverrides", id, selection),
carry: carryVariant,
@@ -737,7 +739,6 @@ export const SessionProvider: ParentComponent = (props) => {
vscode.postMessage({ type: "requestMcpStatus" })
const fallback = setTimeout(() => {
if (!preferencesReady()) vscode.postMessage({ type: "requestModelSelections" })
if (agents().length === 0) vscode.postMessage({ type: "requestAgents" })
if (Object.keys(mcpStatus()).length === 0) vscode.postMessage({ type: "requestMcpStatus" })
}, 3000)
@@ -746,7 +747,7 @@ export const SessionProvider: ParentComponent = (props) => {
if (message.type !== "extensionDataReady") return
unsubReady()
clearTimeout(fallback)
if (!preferencesReady()) vscode.postMessage({ type: "requestModelSelections" })
retryPreferences()
if (agents().length === 0) vscode.postMessage({ type: "requestAgents" })
if (Object.keys(mcpStatus()).length === 0) vscode.postMessage({ type: "requestMcpStatus" })
})
@@ -781,7 +782,11 @@ export const SessionProvider: ParentComponent = (props) => {
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
@@ -1170,24 +1175,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)
@@ -2308,7 +2296,11 @@ export const SessionProvider: ParentComponent = (props) => {
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)
// Generated drafts have no history to load; initialize before applying mode overrides.
setStore("messages", effectiveDraftID, [])
}
if (effectiveSelection) {
if (overrides?.agent) {
@@ -2988,6 +2980,7 @@ export const SessionProvider: ParentComponent = (props) => {
preferredSelection,
preferencesReady,
rememberSelection,
trackScopes: memory.track,
costBreakdown,
contextUsage,
modelUsage,
@@ -3029,6 +3022,7 @@ export const SessionProvider: ParentComponent = (props) => {
variantList,
currentVariant,
variantForAgent,
variantPreference: (agent, model) => (model ? variants.saved(model, agent) : undefined),
selectVariant,
revert,
revertedCount,
@@ -240,6 +240,7 @@ export function mockSessionValue(overrides?: {
preferredSelection: () => undefined,
preferencesReady: () => true,
rememberSelection: noop,
trackScopes: () => noop,
costBreakdown: () => [],
contextUsage: () => undefined,
modelUsage: () => undefined,
@@ -268,6 +269,7 @@ export function mockSessionValue(overrides?: {
variantList: () => [],
currentVariant: () => undefined,
variantForAgent: () => undefined,
variantPreference: () => undefined,
selectVariant: noop,
sendMessage: () => true,
sendCommand: () => true,