From 5fcc4231c0a716e1012475b01ce2d77f99fdbe87 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 6 May 2026 17:46:43 +0200 Subject: [PATCH] Fix Agent Manager session selector state (#9922) * tmp * fix(agent-manager): recover session preferences from history * fix(agent-manager): use scoped prompt selections * Delete packages/kilo-vscode/docs/agent-manager-session-preferences.md --- .changeset/steady-agents-think.md | 5 + .../src/agent-manager/WorktreeStateManager.ts | 5 +- .../agent-manager-initial-message.test.ts | 96 ++++++++ .../tests/unit/session-model-store.test.ts | 28 +-- .../tests/unit/session-preferences.test.ts | 72 ++++++ .../tests/unit/session-variant-store.test.ts | 94 ++++++++ .../tests/unit/worktree-state-manager.test.ts | 21 ++ .../agent-manager/AgentManagerApp.tsx | 15 +- .../agent-manager/initial-message.ts | 37 +++ .../src/components/chat/PromptInput.tsx | 20 +- .../src/components/shared/ModeSwitcher.tsx | 13 +- .../src/components/shared/ModelSelector.tsx | 13 +- .../components/shared/ThinkingSelector.tsx | 15 +- .../src/context/session-model-store.ts | 11 +- .../src/context/session-preferences.ts | 25 ++ .../src/context/session-variant-store.ts | 48 ++++ .../webview-ui/src/context/session.tsx | 228 +++++++++++------- .../webview-ui/src/stories/StoryProviders.tsx | 1 + .../src/stories/history.stories.tsx | 1 + .../src/types/messages/extension-messages.ts | 1 + .../webview-ui/src/types/messages/sessions.ts | 2 +- 21 files changed, 610 insertions(+), 141 deletions(-) create mode 100644 .changeset/steady-agents-think.md create mode 100644 packages/kilo-vscode/tests/unit/agent-manager-initial-message.test.ts create mode 100644 packages/kilo-vscode/tests/unit/session-preferences.test.ts create mode 100644 packages/kilo-vscode/tests/unit/session-variant-store.test.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts create mode 100644 packages/kilo-vscode/webview-ui/src/context/session-preferences.ts create mode 100644 packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts diff --git a/.changeset/steady-agents-think.md b/.changeset/steady-agents-think.md new file mode 100644 index 00000000000..c3278a9d444 --- /dev/null +++ b/.changeset/steady-agents-think.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep Agent Manager model, mode, and reasoning effort selections independent per session and preserve them after restart. diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 478c2f71daf..2bbe74ce358 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -600,8 +600,9 @@ export class WorktreeStateManager { let pruned = 0 for (const [id, s] of Object.entries(data.sessions ?? {})) { const ref = s.worktreeId + const session: ManagedSession = { id, worktreeId: s.worktreeId, createdAt: s.createdAt } if (ref === null) { - this.sessions.set(id, { id, ...s }) + this.sessions.set(id, session) continue } // Skip orphaned sessions referencing a deleted worktree. @@ -609,7 +610,7 @@ export class WorktreeStateManager { pruned++ continue } - this.sessions.set(id, { id, ...s }) + this.sessions.set(id, session) } for (const [id, sec] of Object.entries(data.sections ?? {})) { this.sections.set(id, { id, ...sec }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-initial-message.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-initial-message.test.ts new file mode 100644 index 00000000000..cba84607892 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-initial-message.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "bun:test" +import { initialMessage, initialVariant, seedInitialVariant } from "../../webview-ui/agent-manager/initial-message" + +describe("Agent Manager initial message", () => { + it("forwards the selected variant to sendMessage", () => { + const msg = initialMessage({ + type: "agentManager.sendInitialMessage", + sessionId: "session-a", + worktreeId: "wt-a", + text: "Fix it", + providerID: "anthropic", + modelID: "claude-sonnet-4", + agent: "code", + variant: "high", + }) + + expect(msg).toEqual({ + type: "sendMessage", + text: "Fix it", + sessionID: "session-a", + providerID: "anthropic", + modelID: "claude-sonnet-4", + agent: "code", + variant: "high", + files: undefined, + }) + }) + + it("does not create an empty sendMessage payload", () => { + expect( + initialMessage({ + type: "agentManager.sendInitialMessage", + sessionId: "session-a", + worktreeId: "wt-a", + }), + ).toBeUndefined() + }) + + it("builds the initial session variant state", () => { + const state = initialVariant( + { + type: "agentManager.sendInitialMessage", + sessionId: "session-a", + worktreeId: "wt-a", + providerID: "anthropic", + modelID: "claude-sonnet-4", + variant: "medium", + }, + "code", + ) + + expect(state).toEqual({ + sessionID: "session-a", + providerID: "anthropic", + modelID: "claude-sonnet-4", + agent: "code", + value: "medium", + }) + }) + + it("does not build variant state without a complete model variant", () => { + expect( + initialVariant( + { + type: "agentManager.sendInitialMessage", + sessionId: "session-a", + worktreeId: "wt-a", + providerID: "anthropic", + modelID: "claude-sonnet-4", + }, + "code", + ), + ).toBeUndefined() + }) + + it("seeds initial variant state into the session store", () => { + const calls: unknown[] = [] + + seedInitialVariant( + { + getSessionAgent: () => "code", + setSessionVariant: (...args) => calls.push(args), + }, + { + type: "agentManager.sendInitialMessage", + sessionId: "session-a", + worktreeId: "wt-a", + providerID: "anthropic", + modelID: "claude-sonnet-4", + variant: "medium", + }, + ) + + expect(calls).toEqual([["session-a", "anthropic", "claude-sonnet-4", "medium", "code"]]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-model-store.test.ts b/packages/kilo-vscode/tests/unit/session-model-store.test.ts index 22fd2b4871f..66409719064 100644 --- a/packages/kilo-vscode/tests/unit/session-model-store.test.ts +++ b/packages/kilo-vscode/tests/unit/session-model-store.test.ts @@ -47,7 +47,7 @@ const claude: ModelSelection = { providerID: "anthropic", modelID: "claude-sonne const gpt: ModelSelection = { providerID: "openai", modelID: "gpt-4.1" } describe("per-session model selection", () => { - it("selecting a model in session A writes per-mode globally", () => { + it("selecting a model in session A does not write per-mode globally", () => { const store = emptyStore() const e = env() @@ -58,10 +58,9 @@ describe("per-session model selection", () => { // Session A should see claude (via session override) expect(getSessionModel(updated, e, "session-a", "code")).toEqual(claude) - // Session B (no override) inherits the per-mode global selection. - // This matches CLI behavior: per-mode model is global, not per-session. + // Session B (no override) keeps the default model. const sessionB = getSessionModel(updated, e, "session-b", "code") - expect(sessionB).toEqual(claude) + expect(sessionB).toEqual(KILO_AUTO) }) it("each session preserves its own model independently", () => { @@ -150,15 +149,15 @@ describe("per-session model selection", () => { }) describe("per-mode model memory", () => { - it("applyModel in a session writes to both sessionOverrides and modelSelections", () => { + it("applyModel in a session writes only to sessionOverrides", () => { const store = emptyStore() const result = applyModel(store, "code", claude, "session-a") expect(result.sessionOverrides["session-a"]).toEqual(claude) - expect(result.modelSelections["code"]).toEqual(claude) + expect(result.modelSelections["code"]).toBeUndefined() }) - it("switching modes restores per-mode model after session override is cleared", () => { + it("switching modes falls back to default after session override is cleared", () => { let store = emptyStore() const e = env() @@ -169,20 +168,19 @@ describe("per-mode model memory", () => { // Simulate mode switch: clear session override (like selectAgent does) const cleared = { ...store, sessionOverrides: {} } - // The global modelSelections["code"] still has claude - expect(getSelected(cleared, e, "session-a", "code")).toEqual(claude) + expect(getSelected(cleared, e, "session-a", "code")).toEqual(KILO_AUTO) }) it("different modes remember their own model independently", () => { let store = emptyStore() const e = env() - // User picks claude for "code" in session A - let result = applyModel(store, "code", claude, "session-a") + // User picks claude for "code" globally + let result = applyModel(store, "code", claude, undefined) store = { ...store, ...result } - // User switches to "ask" mode and picks gpt - result = applyModel(store, "ask", gpt, "session-a") + // User switches to "ask" mode and picks gpt globally + result = applyModel(store, "ask", gpt, undefined) store = { ...store, ...result } // Clear session overrides (simulating mode switch) @@ -207,8 +205,8 @@ describe("per-mode model memory", () => { // Session A sees gpt (its override), not the global claude expect(getSelected(store, e, "session-a", "code")).toEqual(gpt) - // Global modelSelections was updated to gpt (last write wins) - expect(store.modelSelections["code"]).toEqual(gpt) + // Global modelSelections stays at the sidebar/default choice. + expect(store.modelSelections["code"]).toEqual(claude) }) it("applyModel without session only writes to modelSelections, not sessionOverrides", () => { diff --git a/packages/kilo-vscode/tests/unit/session-preferences.test.ts b/packages/kilo-vscode/tests/unit/session-preferences.test.ts new file mode 100644 index 00000000000..9da16148386 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-preferences.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test" +import { resolveMessagePrefs } from "../../webview-ui/src/context/session-preferences" +import type { Message } from "../../webview-ui/src/types/messages" + +function msg(input: Partial): Message { + return { + id: input.id ?? "msg", + sessionID: input.sessionID ?? "session-a", + role: input.role ?? "user", + createdAt: input.createdAt ?? "2026-01-01T00:00:00.000Z", + ...input, + } +} + +const agents = new Set(["code", "ask"]) + +describe("session preference recovery", () => { + it("recovers model, variant, and agent from the latest user message", () => { + const prefs = resolveMessagePrefs( + [ + msg({ + id: "old", + agent: "ask", + model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "low" }, + }), + msg({ + id: "new", + agent: "code", + model: { providerID: "openai", modelID: "gpt-5.5", variant: "medium" }, + }), + ], + agents, + ) + + expect(prefs).toEqual({ + agent: "code", + model: { providerID: "openai", modelID: "gpt-5.5" }, + variant: "medium", + }) + }) + + it("ignores assistant-only model data and invalid agents", () => { + const prefs = resolveMessagePrefs( + [ + msg({ + role: "assistant", + agent: "task", + model: { providerID: "openai", modelID: "gpt-5.5", variant: "high" }, + }), + ], + agents, + ) + + expect(prefs).toEqual({}) + }) + + it("can recover the latest valid agent separately from the latest user model", () => { + const prefs = resolveMessagePrefs( + [ + msg({ agent: "ask", model: { providerID: "anthropic", modelID: "claude-sonnet-4" } }), + msg({ role: "assistant", agent: "code" }), + ], + agents, + ) + + expect(prefs).toEqual({ + agent: "code", + model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + variant: undefined, + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-variant-store.test.ts b/packages/kilo-vscode/tests/unit/session-variant-store.test.ts new file mode 100644 index 00000000000..11901449db8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-variant-store.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "bun:test" +import { + getVariant, + sessionVariantKeys, + sessionVariants, + transferVariants, + variantKey, +} from "../../webview-ui/src/context/session-variant-store" +import type { ModelSelection } from "../../webview-ui/src/types/messages" + +const model: ModelSelection = { providerID: "anthropic", modelID: "claude-sonnet-4" } +const variants = ["low", "medium", "high"] + +describe("per-session variant selection", () => { + it("keeps reasoning effort independent for each Agent Manager session", () => { + const store: Record = {} + + store[variantKey(model, "code", "session-a")] = "low" + store[variantKey(model, "code", "session-b")] = "high" + + expect(getVariant(store, model, variants, "code", "session-a")).toBe("low") + expect(getVariant(store, model, variants, "code", "session-b")).toBe("high") + }) + + it("keeps reasoning effort independent for each pending local tab", () => { + const store: Record = {} + + store[variantKey(model, "code", "pending-local-1")] = "medium" + store[variantKey(model, "code", "pending-local-2")] = "high" + + expect(getVariant(store, model, variants, "code", "pending-local-1")).toBe("medium") + expect(getVariant(store, model, variants, "code", "pending-local-2")).toBe("high") + }) + + it("keeps no-session reasoning effort independent per agent", () => { + const store: Record = {} + + store[variantKey(model, "code")] = "medium" + store[variantKey(model, "ask")] = "high" + + expect(getVariant(store, model, variants, "code")).toBe("medium") + expect(getVariant(store, model, variants, "ask")).toBe("high") + }) + + it("carries the pre-submit agent variant into a newly created session", () => { + const store: Record = {} + + store[variantKey(model, "code")] = "medium" + + expect(getVariant(store, model, variants, "code", "session-a")).toBe("medium") + }) + + it("prefers a session variant over the pre-submit agent variant", () => { + const store: Record = {} + + store[variantKey(model, "code")] = "medium" + store[variantKey(model, "code", "session-a")] = "high" + + expect(getVariant(store, model, variants, "code", "session-a")).toBe("high") + }) + + it("falls back to the legacy provider/model variant key", () => { + const store: Record = { "anthropic/claude-sonnet-4": "medium" } + + expect(getVariant(store, model, variants, "code", "session-a")).toBe("medium") + }) + + it("transfers a pending local tab variant to the created session", () => { + const store: Record = {} + + store[variantKey(model, "code", "pending-local-1")] = "medium" + Object.assign(store, transferVariants(store, "pending-local-1", "session-a")) + + expect(getVariant(store, model, variants, "code", "session-a")).toBe("medium") + }) + + it("extracts persisted session variant preferences", () => { + const store: Record = {} + + store[variantKey(model, "code", "session-a")] = "medium" + store[variantKey(model, "code", "session-b")] = "high" + + expect(sessionVariants(store, "session-a")).toEqual({ "anthropic/claude-sonnet-4": "medium" }) + }) + + it("finds only variant keys for the requested session", () => { + const store: Record = {} + + store[variantKey(model, "code", "pending-local-1")] = "medium" + store[variantKey(model, "code", "pending-local-2")] = "high" + + expect(sessionVariantKeys(store, "pending-local-1")).toEqual(["session/pending-local-1/anthropic/claude-sonnet-4"]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts index a1c104582ff..71c84b1a786 100644 --- a/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts @@ -77,6 +77,27 @@ describe("WorktreeStateManager", () => { expect(s.worktreeId).toBeNull() }) + it("drops obsolete session prefs while loading state", async () => { + const file = path.join(root, ".kilo", "agent-manager.json") + fs.writeFileSync( + file, + JSON.stringify({ + worktrees: {}, + sessions: { + "local-1": { + worktreeId: null, + createdAt: new Date().toISOString(), + prefs: { agent: "code" }, + }, + }, + }), + ) + + await manager.load() + + expect(manager.getSession("local-1")).not.toHaveProperty("prefs") + }) + it("filters sessions by worktreeId", () => { const wt1 = manager.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) const wt2 = manager.addWorktree({ branch: "b", path: "/tmp/b", parentBranch: "main" }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index df117cb4778..974ea6962e6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -114,6 +114,7 @@ import { import { sectionAwareDetector } from "./section-dnd" import { ConstrainDragXAxis } from "./constrain-drag-x" import { mergeWorktreeDiffs } from "./diff-state" +import { initialMessage, seedInitialVariant } from "./initial-message" import { createMarkdownRender } from "./review-preferences" import "./agent-manager.css" import "./agent-manager-review.css" @@ -1407,18 +1408,12 @@ const AgentManagerContent: Component = () => { if (ev.providerID && ev.modelID) { session.setSessionModel(ev.sessionId, ev.providerID, ev.modelID) } + seedInitialVariant(session, ev) // Only send a message if there's text — otherwise just clear busy state - if (ev.text) { - vscode.postMessage({ - type: "sendMessage", - text: ev.text, - sessionID: ev.sessionId, - providerID: ev.providerID, - modelID: ev.modelID, - agent: ev.agent, - files: ev.files, - }) + const init = initialMessage(ev) + if (init) { + vscode.postMessage(init) } // Clear busy state — use worktreeId from the message directly // to avoid race condition where managedSessions() hasn't updated yet diff --git a/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts b/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts new file mode 100644 index 00000000000..15049eec698 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts @@ -0,0 +1,37 @@ +import type { AgentManagerSendInitialMessage, SendMessageRequest } from "../src/types/messages" + +interface VariantSession { + getSessionAgent: (sessionID: string) => string + setSessionVariant: (sessionID: string, providerID: string, modelID: string, value: string, agent?: string) => void +} + +export function initialMessage(ev: AgentManagerSendInitialMessage): SendMessageRequest | undefined { + if (!ev.text) return undefined + return { + type: "sendMessage", + text: ev.text, + sessionID: ev.sessionId, + providerID: ev.providerID, + modelID: ev.modelID, + agent: ev.agent, + variant: ev.variant, + files: ev.files, + } +} + +export function initialVariant(ev: AgentManagerSendInitialMessage, agent: string) { + if (!ev.providerID || !ev.modelID || !ev.variant) return undefined + return { + sessionID: ev.sessionId, + providerID: ev.providerID, + modelID: ev.modelID, + agent: ev.agent ?? agent, + value: ev.variant, + } +} + +export function seedInitialVariant(session: VariantSession, ev: AgentManagerSendInitialMessage) { + const state = initialVariant(ev, session.getSessionAgent(ev.sessionId)) + if (!state) return + session.setSessionVariant(state.sessionID, state.providerID, state.modelID, state.value, state.agent) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index f1c28a9966b..30bb1a56af8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -82,7 +82,9 @@ export const PromptInput: Component = (props) => { const mention = useFileMention(vscode, sid, hasGit) const terminal = useTerminalContext(vscode) const git = useGitChangesContext(vscode, ctx, hasGit) - const slash = useSlashCommand(vscode, () => (session.variantList().length > 0 ? new Set() : new Set(["variant"]))) + const slash = useSlashCommand(vscode, () => + session.variantList(sid()).length > 0 ? new Set() : new Set(["variant"]), + ) const imageAttach = useImageAttachments() imageAttach.setFilePathDropHandler((paths) => { const cwd = server.workspaceDirectory() @@ -315,7 +317,7 @@ export const PromptInput: Component = (props) => { const onCompact = () => { if (session.status() === "busy") return if (session.messages().length === 0) return - if (!session.selected()) return + if (!session.selected(sid())) return session.compact() } window.addEventListener("compactSession", onCompact) @@ -387,7 +389,7 @@ export const PromptInput: Component = (props) => { if (message.type === "triggerTask") { if (isDisabled()) return - const sel = session.selected() + const sel = session.selected(sid()) session.sendMessage(message.text, sel?.providerID, sel?.modelID, undefined, undefined, ctx()) } @@ -679,9 +681,9 @@ export const PromptInput: Component = (props) => { const mentionFiles = mention.parseFileAttachments(draft) const imgFiles = imgs.map((img) => ({ mime: img.mime, url: img.dataUrl, filename: img.filename })) - const sel = session.selected() const pendingId = props.pendingSessionID ?? session.draftSessionID() const id = sid() + const sel = session.selected(id) const terminalFile = await terminal.resolveAttachment(message, id).catch((err: Error) => { showToast({ variant: "error", title: "Terminal context unavailable", description: err.message }) @@ -955,15 +957,15 @@ export const PromptInput: Component = (props) => {
- - - - + + + +