mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 01:51:21 +08:00
fix(vscode): preserve effort intent and live session defaults
This commit is contained in:
@@ -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()
|
||||
}
|
||||
+227
-17
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user