diff --git a/packages/kilo-vscode/tests/fixtures/session-preference-loader.ts b/packages/kilo-vscode/tests/fixtures/session-preference-loader.ts new file mode 100644 index 00000000000..58d6354cff0 --- /dev/null +++ b/packages/kilo-vscode/tests/fixtures/session-preference-loader.ts @@ -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 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() +} diff --git a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx index 42ab8addf0c..e5ccb261352 100644 --- a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx +++ b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx @@ -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 | undefined } +const [tabbed, setTabbed] = createSignal(false) +const tabs = { value: undefined as ReturnType | undefined } +const Tabs = () => { + tabs.value = useLocalTabs() + return null +} const Peer = () => { peer.value = useSession() return null @@ -143,6 +151,11 @@ const Probe = () => { } as Parameters[1] return ( + + + + + @@ -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") diff --git a/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts b/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts index e48c6354e6c..c4abe00da7a 100644 --- a/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts +++ b/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts @@ -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(/ 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") diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index 1d1244e1ae8..6ba33e552d2 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -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\)/, ) }) diff --git a/packages/kilo-vscode/tests/unit/session-preference-loader.test.ts b/packages/kilo-vscode/tests/unit/session-preference-loader.test.ts new file mode 100644 index 00000000000..755fb605ba7 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-preference-loader.test.ts @@ -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) + }, + ) +}) diff --git a/packages/kilo-vscode/tests/unit/session-variants.test.ts b/packages/kilo-vscode/tests/unit/session-variants.test.ts index 1014e82a987..d83b5ba6b46 100644 --- a/packages/kilo-vscode/tests/unit/session-variants.test.ts +++ b/packages/kilo-vscode/tests/unit/session-variants.test.ts @@ -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() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 5e191b312f3..5fca82eca22 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -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() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index c3cadf0c29d..5fd9129e7ec 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -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>() 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(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(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(saved.variant) const [sandbox, setSandbox] = createSignal(saved.sandbox) const [sandboxDefault, setSandboxDefault] = createSignal() const [sandboxOverride, setSandboxOverride] = createSignal() @@ -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<{ { - 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} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/new-worktree-models.ts b/packages/kilo-vscode/webview-ui/agent-manager/new-worktree-models.ts index 9aaf32aa2c1..155a68d9040 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/new-worktree-models.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/new-worktree-models.ts @@ -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() }), + } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/registry.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/registry.ts index 2eff7dd6c11..33fb66f04de 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/registry.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/registry.ts @@ -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): 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 } } diff --git a/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx b/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx index 6b993545f1e..9259fdf0130 100644 --- a/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx @@ -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() const fresh = new Set() diff --git a/packages/kilo-vscode/webview-ui/src/context/session-model-preferences.ts b/packages/kilo-vscode/webview-ui/src/context/session-model-preferences.ts index 1d840613a56..cfe977abd82 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-model-preferences.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-model-preferences.ts @@ -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 @@ -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>() + 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() + const sync = (ids: Set) => { + 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) { + 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 } } diff --git a/packages/kilo-vscode/webview-ui/src/context/session-preference-loader.ts b/packages/kilo-vscode/webview-ui/src/context/session-preference-loader.ts new file mode 100644 index 00000000000..7adcdeb1cd4 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/session-preference-loader.ts @@ -0,0 +1,55 @@ +import { createComputed, on, onCleanup, type Accessor } from "solid-js" + +export function createPreferenceLoader(opts: { + ready: Accessor + connected: Accessor + request: () => void +}): () => void { + let timer: ReturnType | 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 +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session-types.ts b/packages/kilo-vscode/webview-ui/src/context/session-types.ts index 7d7daec36a1..0619701a6d6 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-types.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-types.ts @@ -115,6 +115,7 @@ export interface SessionContextValue { preferredSelection: Accessor<(ModelSelection & { variant?: string }) | undefined> preferencesReady: Accessor rememberSelection: (agent: string, model: ModelSelection, variant?: string) => void + trackScopes: (ids: Accessor) => () => void // Cost and context usage for the current session costBreakdown: Accessor> @@ -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 diff --git a/packages/kilo-vscode/webview-ui/src/context/session-variants.ts b/packages/kilo-vscode/webview-ui/src/context/session-variants.ts index e5be9564de0..a0ab331b5a5 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-variants.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-variants.ts @@ -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 } } diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 71a8a9ad2fe..7f55e5df8e7 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -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, diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index cfbc5aa5fe5..910bd5ba424 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -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,