diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index ba24906f0f9..c425f9bac41 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1910,32 +1910,41 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // races with the async config.update() write on the CLI backend). this.pending++ try { - await this.client.global.config.update({ config: partial }, { throwOnError: true }) + try { + await this.client.global.config.update({ config: partial }, { throwOnError: true }) + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to update config:", error) + this.postMessage({ + type: "error", + message: getErrorMessage(error) || "Failed to update config", + }) + this.postMessage({ + type: "configSaveFailed", + config: (this.cachedConfigMessage as { config: unknown } | undefined)?.config ?? {}, + }) + return + } - // Re-fetch the full merged config (global + project + all layers) so the - // webview receives the complete resolved config, not just global-only data. - // Config.state is reset by updateGlobal (via Instance.resetStateEntry) so - // config.get() returns fresh data without a full dispose cycle. - const dir = this.getWorkspaceDirectory() - const { data: merged } = await this.client.config.get({ directory: dir }, { throwOnError: true }) + try { + // Re-fetch the full merged config (global + project + all layers) so the + // webview receives the complete resolved config, not just global-only data. + // Config.state is reset by updateGlobal (via Instance.resetStateEntry) so + // config.get() returns fresh data without a full dispose cycle. + const dir = this.getWorkspaceDirectory() + const { data: merged } = await this.client.config.get({ directory: dir }, { throwOnError: true }) - this.cachedConfigMessage = { type: "configLoaded", config: merged } - this.postMessage({ type: "configUpdated", config: merged }) + this.cachedConfigMessage = { type: "configLoaded", config: merged } + this.postMessage({ type: "configUpdated", config: merged }) + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to fetch merged config after update:", error) + // The write already succeeded; keep the optimistic state and wait for + // the SSE-driven refresh to deliver the authoritative merged config. + this.postMessage({ type: "configSaved" }) + } if (refreshProviders) { await this.fetchAndSendProviders() } - } catch (error) { - console.error("[Kilo New] KiloProvider: Failed to update config:", error) - this.postMessage({ - type: "error", - message: getErrorMessage(error) || "Failed to update config", - }) - // Send configUpdated with the last known good config so the webview - // clears its saving flag and reverts optimistic state. - if (this.cachedConfigMessage) { - this.postMessage({ type: "configUpdated", config: (this.cachedConfigMessage as { config: unknown }).config }) - } } finally { this.pending-- } diff --git a/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts b/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts new file mode 100644 index 00000000000..0820dc818e9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "bun:test" +import { + selectedAgentNumberOverrideValue, + selectedAgentTextOverrideValue, + selectedDefaultAgentValue, + shouldClearDefaultAgentWhenAgentBecomesUnavailable, +} from "../../webview-ui/src/components/settings/agent-behaviour-patches" + +describe("selectedAgentTextOverrideValue", () => { + it("maps an empty text field value to a null delete sentinel", () => { + expect(selectedAgentTextOverrideValue("")).toBeNull() + }) + + it("preserves a non-empty text override", () => { + expect(selectedAgentTextOverrideValue("Review code")).toBe("Review code") + }) +}) + +describe("selectedAgentNumberOverrideValue", () => { + it("maps a blank numeric field value to a null delete sentinel", () => { + expect(selectedAgentNumberOverrideValue("", parseFloat)).toBeNull() + }) + + it("preserves a valid numeric override", () => { + expect(selectedAgentNumberOverrideValue("0.7", parseFloat)).toBe(0.7) + }) + + it("keeps invalid non-empty numeric input out of the persisted patch", () => { + expect(selectedAgentNumberOverrideValue("abc", parseFloat)).toBeUndefined() + }) +}) + +describe("selectedDefaultAgentValue", () => { + it("maps an empty dropdown value to a null delete sentinel", () => { + expect(selectedDefaultAgentValue("")).toBeNull() + }) + + it("preserves a non-empty agent selection", () => { + expect(selectedDefaultAgentValue("code")).toBe("code") + }) +}) + +describe("shouldClearDefaultAgentWhenAgentBecomesUnavailable", () => { + it("clears when the current default agent becomes unavailable", () => { + expect(shouldClearDefaultAgentWhenAgentBecomesUnavailable(true, "code", "code")).toBe(true) + }) + + it("does not clear when toggling a non-default agent", () => { + expect(shouldClearDefaultAgentWhenAgentBecomesUnavailable(true, "code", "plan")).toBe(false) + }) + + it("does not clear when the agent remains available", () => { + expect(shouldClearDefaultAgentWhenAgentBecomesUnavailable(false, "code", "code")).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/config-utils.test.ts b/packages/kilo-vscode/tests/unit/config-utils.test.ts index 9a69c4be156..a7fdd86a3a6 100644 --- a/packages/kilo-vscode/tests/unit/config-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/config-utils.test.ts @@ -189,6 +189,71 @@ describe("ConfigState", () => { expect(s.saving).toBe(false) expect(Object.keys(s.draft).length).toBe(0) }) + + it("preserves the null delete sentinel in the pending save payload", () => { + const s = new ConfigState() + s.handleConfigLoaded({ default_agent: "code" }) + s.updateConfig({ default_agent: null }) + + expect(s.draft.default_agent).toBeNull() + + s.saveConfig() + + expect(s.saving).toBe(true) + expect(s.draft.default_agent).toBeNull() + }) + }) + + describe("configSaved while a save is in-flight", () => { + it("clears the draft after a confirmed write even if merged refresh is pending", () => { + const s = new ConfigState() + s.handleConfigLoaded({ agent: { code: { prompt: "Review" } } }) + s.updateConfig({ agent: { code: { prompt: null } } }) + s.saveConfig() + + s.handleConfigSaved() + + expect(s.saving).toBe(false) + expect(s.dirty).toBe(false) + expect(Object.keys(s.draft).length).toBe(0) + expect(s.saved.agent?.code?.prompt).toBeUndefined() + expect(s.config.agent?.code?.prompt).toBeUndefined() + }) + }) + + describe("configSaveFailed while a save is in-flight", () => { + it("preserves pending null-sentinel clears so the user can retry", () => { + const s = new ConfigState() + s.handleConfigLoaded({ agent: { code: { prompt: "Review", temperature: 0.7 } }, default_agent: "code" }) + s.updateConfig({ agent: { code: { prompt: null, temperature: null } } }) + s.updateConfig({ default_agent: null }) + s.saveConfig() + + s.handleConfigSaveFailed({ agent: { code: { prompt: "Review", temperature: 0.7 } }, default_agent: "code" }) + + expect(s.saving).toBe(false) + expect(s.dirty).toBe(true) + expect(s.draft.agent?.code?.prompt).toBeNull() + expect(s.draft.agent?.code?.temperature).toBeNull() + expect(s.draft.default_agent).toBeNull() + expect(s.config.agent?.code?.prompt).toBeUndefined() + expect(s.config.agent?.code?.temperature).toBeUndefined() + expect(s.config.default_agent).toBeUndefined() + }) + }) + + it("ignores repeated save attempts while a save is already in-flight", () => { + const s = new ConfigState() + s.handleConfigLoaded({ snapshot: true }) + s.updateConfig({ snapshot: false }) + + s.saveConfig() + s.saveConfig() + s.handleConfigUpdated({ snapshot: false }) + + expect(s.saving).toBe(false) + expect(s.dirty).toBe(false) + expect(s.config.snapshot).toBe(false) }) it("configLoaded is ignored while save is in-flight", () => { diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx index 345f8d75633..1f9d6b680b6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx @@ -15,6 +15,7 @@ import ModeEditView from "./ModeEditView" import ModeCreateView from "./ModeCreateView" import McpEditView from "./McpEditView" import WorkflowsTab from "./agent-behaviour/WorkflowsTab" +import { selectedDefaultAgentValue } from "./agent-behaviour-patches" import { parseImport, MAX_IMPORT_SIZE } from "./mode-io" import type { ImportError } from "./mode-io" @@ -274,7 +275,7 @@ const AgentBehaviourTab: Component = () => { label={(o) => o.label} onSelect={(o) => { if (!o) return - const next = o.value || null + const next = selectedDefaultAgentValue(o.value) if (next === (config().default_agent ?? null)) return updateConfig({ default_agent: next }) }} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ModeEditView.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ModeEditView.tsx index 3142fa391f9..37a469f2ead 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ModeEditView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ModeEditView.tsx @@ -10,6 +10,11 @@ import { useSession } from "../../context/session" import { useLanguage } from "../../context/language" import type { AgentConfig, AgentInfo } from "../../types/messages" import SettingsRow from "./SettingsRow" +import { + selectedAgentNumberOverrideValue, + selectedAgentTextOverrideValue, + shouldClearDefaultAgentWhenAgentBecomesUnavailable, +} from "./agent-behaviour-patches" import { buildExport } from "./mode-io" interface Props { @@ -42,6 +47,16 @@ const ModeEditView: Component = (props) => { }) } + const updateNumberOverride = ( + key: "temperature" | "top_p" | "steps", + rawValue: string, + parse: (value: string) => number, + ) => { + const next = selectedAgentNumberOverrideValue(rawValue, parse) + if (rawValue.trim() !== "" && next === undefined) return + update({ [key]: next } as Pick) + } + const exportMode = () => { const data = buildExport(props.name, cfg()) const json = JSON.stringify(data, null, 2) @@ -106,19 +121,16 @@ const ModeEditView: Component = (props) => { - {/* Description (full-width, custom modes only) */} - - -
- {language.t("settings.agentBehaviour.editMode.description")} -
- update({ description: val || undefined })} - /> -
-
+ +
+ {language.t("settings.agentBehaviour.editMode.description")} +
+ update({ description: selectedAgentTextOverrideValue(val) })} + /> +
{/* Prompt (full-width, auto-resizing) */} @@ -131,7 +143,7 @@ const ModeEditView: Component = (props) => { value={cfg().prompt ?? ""} placeholder={language.t("settings.agentBehaviour.createMode.prompt.placeholder")} multiline - onChange={(val) => update({ prompt: val || undefined })} + onChange={(val) => update({ prompt: selectedAgentTextOverrideValue(val) })} /> @@ -144,7 +156,7 @@ const ModeEditView: Component = (props) => { update({ model: val || undefined })} + onChange={(val) => update({ model: selectedAgentTextOverrideValue(val) })} /> @@ -155,10 +167,7 @@ const ModeEditView: Component = (props) => { { - const parsed = parseFloat(val) - update({ temperature: isNaN(parsed) ? undefined : parsed }) - }} + onChange={(val) => updateNumberOverride("temperature", val, parseFloat)} /> @@ -169,10 +178,7 @@ const ModeEditView: Component = (props) => { { - const parsed = parseFloat(val) - update({ top_p: isNaN(parsed) ? undefined : parsed }) - }} + onChange={(val) => updateNumberOverride("top_p", val, parseFloat)} /> @@ -183,10 +189,7 @@ const ModeEditView: Component = (props) => { { - const parsed = parseInt(val, 10) - update({ steps: isNaN(parsed) ? undefined : parsed }) - }} + onChange={(val) => updateNumberOverride("steps", val, (raw) => parseInt(raw, 10))} /> @@ -199,7 +202,7 @@ const ModeEditView: Component = (props) => { onChange={(val) => { update({ hidden: val }) // Clear default_agent if hiding the current default - if (val && config().default_agent === props.name) { + if (shouldClearDefaultAgentWhenAgentBecomesUnavailable(val, config().default_agent, props.name)) { updateConfig({ default_agent: null }) } }} @@ -219,7 +222,7 @@ const ModeEditView: Component = (props) => { onChange={(val) => { update({ disable: val }) // Clear default_agent if disabling the current default - if (val && config().default_agent === props.name) { + if (shouldClearDefaultAgentWhenAgentBecomesUnavailable(val, config().default_agent, props.name)) { updateConfig({ default_agent: null }) } }} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour-patches.ts b/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour-patches.ts new file mode 100644 index 00000000000..7d031e2a22b --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour-patches.ts @@ -0,0 +1,24 @@ +export function selectedDefaultAgentValue(value: string): string | null { + return value || null +} + +export function selectedAgentTextOverrideValue(value: string): string | null { + return value === "" ? null : value +} + +export function selectedAgentNumberOverrideValue( + value: string, + parse: (value: string) => number, +): number | null | undefined { + if (value.trim() === "") return null + const parsed = parse(value) + return Number.isNaN(parsed) ? undefined : parsed +} + +export function shouldClearDefaultAgentWhenAgentBecomesUnavailable( + nextValue: boolean, + currentDefaultAgent: string | null | undefined, + agentName: string, +): boolean { + return nextValue && currentDefaultAgent === agentName +} diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 015c4934bbf..6200600c474 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -67,6 +67,22 @@ export const ConfigProvider: ParentComponent = (props) => { setSaved(message.config) return } + if (message.type === "configSaved") { + if (!saving) return + const confirmedConfig = config() + saving = false + setDraft({}) + setIsDirty(false) + setSaved(confirmedConfig) + return + } + if (message.type === "configSaveFailed") { + if (!saving) return + saving = false + setSaved(message.config) + setConfig(resolveConfig(message.config, draft(), isDirty())) + return + } }) onCleanup(unsubscribe) @@ -101,7 +117,7 @@ export const ConfigProvider: ParentComponent = (props) => { function saveConfig() { const changes = draft() - if (Object.keys(changes).length === 0) return + if (saving || Object.keys(changes).length === 0) return // Don't clear draft/isDirty yet — wait for configUpdated confirmation. // If the write fails, the save bar stays visible so the user can retry. saving = true diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 83975fcbddc..72a756ab765 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -321,14 +321,14 @@ export type PermissionConfig = Partial> export interface AgentConfig { model?: string | null - prompt?: string - description?: string + prompt?: string | null + description?: string | null mode?: "subagent" | "primary" | "all" hidden?: boolean disable?: boolean - temperature?: number - top_p?: number - steps?: number + temperature?: number | null + top_p?: number | null + steps?: number | null permission?: PermissionConfig } @@ -722,6 +722,15 @@ export interface ConfigUpdatedMessage { config: Config } +export interface ConfigSavedMessage { + type: "configSaved" +} + +export interface ConfigSaveFailedMessage { + type: "configSaveFailed" + config: Config +} + export interface GlobalConfigLoadedMessage { type: "globalConfigLoaded" config: Config @@ -1277,6 +1286,8 @@ export type ExtensionMessage = | BrowserSettingsLoadedMessage | ConfigLoadedMessage | ConfigUpdatedMessage + | ConfigSavedMessage + | ConfigSaveFailedMessage | GlobalConfigLoadedMessage | NotificationSettingsLoadedMessage | NotificationsLoadedMessage diff --git a/packages/kilo-vscode/webview-ui/src/utils/config-utils.ts b/packages/kilo-vscode/webview-ui/src/utils/config-utils.ts index f4f3880a800..0031da2d74a 100644 --- a/packages/kilo-vscode/webview-ui/src/utils/config-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/utils/config-utils.ts @@ -81,9 +81,26 @@ export class ConfigState { this.saved = server } + /** Handle a confirmed save when merged config refresh is still pending. */ + handleConfigSaved() { + if (!this.saving) return + this.saving = false + this.draft = {} + this.dirty = false + this.saved = this.config + } + + /** Handle an explicit save failure from the extension. */ + handleConfigSaveFailed(server: Config) { + if (!this.saving) return + this.saving = false + this.saved = server + this.config = resolveConfig(server, this.draft, this.dirty) + } + /** Send the draft to the backend. */ saveConfig() { - if (Object.keys(this.draft).length === 0) return + if (this.saving || Object.keys(this.draft).length === 0) return this.saving = true } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1c0cd8a5222..4e0a65c9a65 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -819,12 +819,12 @@ export namespace Config { .string() .optional() .describe("Default model variant for this agent (applies only when using the agent's configured model)."), - temperature: z.number().optional(), - top_p: z.number().optional(), - prompt: z.string().optional(), + temperature: z.number().nullable().optional(), // kilocode_change - nullable for delete sentinel + top_p: z.number().nullable().optional(), // kilocode_change - nullable for delete sentinel + prompt: z.string().nullable().optional(), // kilocode_change - nullable for delete sentinel tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"), disable: z.boolean().optional(), - description: z.string().optional().describe("Description of when to use the agent"), + description: z.string().nullable().optional().describe("Description of when to use the agent"), mode: z.enum(["subagent", "primary", "all"]).optional(), hidden: z .boolean() @@ -842,6 +842,7 @@ export namespace Config { .number() .int() .positive() + .nullable() .optional() .describe("Maximum number of agentic iterations before forcing text-only response"), maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6928101b137..028420a6e77 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1113,9 +1113,9 @@ export type AgentConfig = { * Default model variant for this agent (applies only when using the agent's configured model). */ variant?: string - temperature?: number - top_p?: number - prompt?: string + temperature?: number | null + top_p?: number | null + prompt?: string | null /** * @deprecated Use 'permission' field instead */ @@ -1126,7 +1126,7 @@ export type AgentConfig = { /** * Description of when to use the agent */ - description?: string + description?: string | null mode?: "subagent" | "primary" | "all" /** * Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent) @@ -1142,7 +1142,7 @@ export type AgentConfig = { /** * Maximum number of agentic iterations before forcing text-only response */ - steps?: number + steps?: number | null /** * @deprecated Use 'steps' field instead. */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 62b3f4c921d..2056357c251 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -12387,13 +12387,34 @@ "type": "string" }, "temperature": { - "type": "number" + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] }, "top_p": { - "type": "number" + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] }, "prompt": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "tools": { "description": "@deprecated Use 'permission' field instead", @@ -12410,7 +12431,14 @@ }, "description": { "description": "Description of when to use the agent", - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "mode": { "type": "string", @@ -12454,9 +12482,16 @@ }, "steps": { "description": "Maximum number of agentic iterations before forcing text-only response", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] }, "maxSteps": { "description": "@deprecated Use 'steps' field instead.",