mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
463 lines
17 KiB
TypeScript
463 lines
17 KiB
TypeScript
import { describe, it, expect } from "bun:test"
|
|
import {
|
|
configUnsetPaths,
|
|
ConfigState,
|
|
deepMerge,
|
|
mergeScopedConfig,
|
|
pruneConfigSet,
|
|
stripNulls,
|
|
} from "../../webview-ui/src/utils/config-utils"
|
|
import type { Config } from "../../webview-ui/src/types/messages"
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pure helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("deepMerge", () => {
|
|
it("overrides scalar values", () => {
|
|
const target: Config = { snapshot: true }
|
|
const source: Partial<Config> = { snapshot: false }
|
|
expect(deepMerge(target, source)).toEqual({ snapshot: false })
|
|
})
|
|
|
|
it("merges nested objects recursively", () => {
|
|
const target: Config = { agent: { code: { temperature: 0.5 } } }
|
|
const source: Partial<Config> = { agent: { code: { steps: 10 } } }
|
|
const result = deepMerge(target, source)
|
|
expect(result.agent?.code?.temperature).toBe(0.5)
|
|
expect(result.agent?.code?.steps).toBe(10)
|
|
})
|
|
|
|
it("preserves keys not present in source", () => {
|
|
const target: Config = { snapshot: true, username: "alice" }
|
|
const source: Partial<Config> = { snapshot: false }
|
|
expect(deepMerge(target, source)).toEqual({ snapshot: false, username: "alice" })
|
|
})
|
|
|
|
it("replaces arrays instead of merging them", () => {
|
|
const target: Config = { instructions: ["a", "b"] }
|
|
const source: Partial<Config> = { instructions: ["c"] }
|
|
expect(deepMerge(target, source)).toEqual({ instructions: ["c"] })
|
|
})
|
|
|
|
it("preserves explicit false values in nested agent config", () => {
|
|
const target: Config = { agent: { code: { disable: true, hidden: true } } }
|
|
const source: Partial<Config> = { agent: { code: { disable: false, hidden: false } } }
|
|
const result = deepMerge(target, source)
|
|
expect(result.agent?.code?.disable).toBe(false)
|
|
expect(result.agent?.code?.hidden).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("scoped config normalization", () => {
|
|
it("preserves indexing null overrides while stripping unrelated nulls", () => {
|
|
const target = { username: "alice", indexing: { model: "global", dimension: 1024 } } as Config
|
|
const source = { username: null, indexing: { model: null, dimension: null } } as unknown as Partial<Config>
|
|
|
|
expect(mergeScopedConfig(target, source)).toEqual({ indexing: { model: null, dimension: null } })
|
|
})
|
|
|
|
it("builds clean set and unset payloads while preserving indexing null overrides", () => {
|
|
const patch = {
|
|
formatter: {},
|
|
username: null,
|
|
indexing: {
|
|
model: null,
|
|
dimension: null,
|
|
searchMinScore: undefined,
|
|
qdrant: { apiKey: undefined },
|
|
},
|
|
}
|
|
|
|
expect(pruneConfigSet(patch)).toEqual({
|
|
formatter: {},
|
|
indexing: { model: null, dimension: null },
|
|
})
|
|
expect(configUnsetPaths(patch)).toEqual([
|
|
["username"],
|
|
["indexing", "searchMinScore"],
|
|
["indexing", "qdrant", "apiKey"],
|
|
])
|
|
})
|
|
})
|
|
|
|
describe("stripNulls", () => {
|
|
it("removes null values", () => {
|
|
const cfg = { snapshot: true, username: null } as unknown as Config
|
|
expect(stripNulls(cfg)).toEqual({ snapshot: true })
|
|
})
|
|
|
|
it("removes undefined values", () => {
|
|
const cfg = { snapshot: true, username: undefined } as unknown as Config
|
|
expect(stripNulls(cfg)).toEqual({ snapshot: true })
|
|
})
|
|
|
|
it("strips nulls recursively in nested objects", () => {
|
|
const cfg = { agent: { code: { temperature: 0.5, prompt: null } } } as unknown as Config
|
|
expect(stripNulls(cfg)).toEqual({ agent: { code: { temperature: 0.5 } } })
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config state machine — reproduces the actual message-handler flow
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("ConfigState", () => {
|
|
it("configLoaded sets config when no draft is pending", () => {
|
|
const s = new ConfigState()
|
|
s.handleConfigLoaded({ snapshot: true, username: "alice" })
|
|
expect(s.config).toEqual({ snapshot: true, username: "alice" })
|
|
expect(s.loading).toBe(false)
|
|
})
|
|
|
|
describe("configLoaded while draft is pending (the reported bug)", () => {
|
|
it("preserves the user's pending toggle change", () => {
|
|
const s = new ConfigState()
|
|
|
|
// 1. Server sends initial config
|
|
s.handleConfigLoaded({ snapshot: true, username: "alice" })
|
|
expect(s.config.snapshot).toBe(true)
|
|
|
|
// 2. User toggles snapshot off (but hasn't saved yet)
|
|
s.updateConfig({ snapshot: false })
|
|
expect(s.config.snapshot).toBe(false)
|
|
expect(s.dirty).toBe(true)
|
|
|
|
// 3. A configLoaded push arrives from the extension (e.g. SSE event,
|
|
// tab switch, or another webview triggers a config reload).
|
|
// The server still has snapshot: true.
|
|
s.handleConfigLoaded({ snapshot: true, username: "alice" })
|
|
|
|
// BUG (old code): config.snapshot would be reset to true here
|
|
// FIX: the draft is re-applied, so the user's toggle stays false
|
|
expect(s.config.snapshot).toBe(false)
|
|
expect(s.config.username).toBe("alice")
|
|
expect(s.dirty).toBe(true)
|
|
})
|
|
|
|
it("preserves nested draft changes across configLoaded pushes", () => {
|
|
const s = new ConfigState()
|
|
s.handleConfigLoaded({ agent: { code: { temperature: 0.7 } } })
|
|
s.updateConfig({ agent: { code: { steps: 5 } } })
|
|
|
|
// Server pushes a reload — temperature may have changed server-side
|
|
s.handleConfigLoaded({ agent: { code: { temperature: 0.9 } } })
|
|
|
|
expect(s.config.agent?.code?.steps).toBe(5)
|
|
expect(s.config.agent?.code?.temperature).toBe(0.9)
|
|
})
|
|
|
|
it("preserves explicit false agent flags across configLoaded pushes", () => {
|
|
const s = new ConfigState()
|
|
s.handleConfigLoaded({ agent: { code: { disable: true, hidden: true } } })
|
|
s.updateConfig({ agent: { code: { disable: false, hidden: false } } })
|
|
|
|
s.handleConfigLoaded({ agent: { code: { disable: true, hidden: true } } })
|
|
|
|
expect(s.config.agent?.code?.disable).toBe(false)
|
|
expect(s.config.agent?.code?.hidden).toBe(false)
|
|
})
|
|
|
|
it("preserves clearing default_agent when the current default is hidden", () => {
|
|
const s = new ConfigState()
|
|
s.handleConfigLoaded({ default_agent: "code", agent: { code: { hidden: false } } })
|
|
|
|
s.updateConfig({ agent: { code: { hidden: true } } })
|
|
s.updateConfig({ default_agent: null })
|
|
|
|
s.handleConfigLoaded({ default_agent: "code", agent: { code: { hidden: false } } })
|
|
|
|
expect(s.config.agent?.code?.hidden).toBe(true)
|
|
expect(s.config.default_agent).toBeUndefined()
|
|
})
|
|
|
|
it("preserves clearing default_agent when the current default is disabled", () => {
|
|
const s = new ConfigState()
|
|
s.handleConfigLoaded({ default_agent: "code", agent: { code: { disable: false } } })
|
|
|
|
s.updateConfig({ agent: { code: { disable: true } } })
|
|
s.updateConfig({ default_agent: null })
|
|
|
|
s.handleConfigLoaded({ default_agent: "code", agent: { code: { disable: false } } })
|
|
|
|
expect(s.config.agent?.code?.disable).toBe(true)
|
|
expect(s.config.default_agent).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe("configUpdated while draft is pending", () => {
|
|
it("preserves draft when update comes from another source", () => {
|
|
const s = new ConfigState()
|
|
s.handleConfigLoaded({ snapshot: true, username: "alice" })
|
|
s.updateConfig({ snapshot: false })
|
|
|
|
// Another webview (e.g. PermissionDock) saves a different setting
|
|
s.handleConfigUpdated({ snapshot: true, username: "bob" })
|
|
|
|
expect(s.config.snapshot).toBe(false) // draft preserved
|
|
expect(s.config.username).toBe("bob") // server update applied
|
|
})
|
|
|
|
it("clears draft when update confirms our save", () => {
|
|
const s = new ConfigState()
|
|
s.handleConfigLoaded({ snapshot: true })
|
|
s.updateConfig({ snapshot: false })
|
|
s.saveConfig()
|
|
expect(s.saving).toBe(true)
|
|
|
|
// Server confirms the write
|
|
s.handleConfigUpdated({ snapshot: false })
|
|
|
|
expect(s.config.snapshot).toBe(false)
|
|
expect(s.dirty).toBe(false)
|
|
expect(s.saving).toBe(false)
|
|
expect(Object.keys(s.draft).length).toBe(0)
|
|
})
|
|
|
|
it("clears default_agent when update confirms a null-sentinel save", () => {
|
|
const s = new ConfigState()
|
|
s.handleConfigLoaded({ default_agent: "code" })
|
|
s.updateConfig({ default_agent: null })
|
|
s.saveConfig()
|
|
|
|
// Server confirms the write by returning config without default_agent.
|
|
s.handleConfigUpdated({})
|
|
|
|
expect(s.config.default_agent).toBeUndefined()
|
|
expect(s.dirty).toBe(false)
|
|
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", () => {
|
|
const s = new ConfigState()
|
|
s.handleConfigLoaded({ snapshot: true })
|
|
s.updateConfig({ snapshot: false })
|
|
s.saveConfig()
|
|
|
|
// A stale configLoaded arrives during the write round-trip
|
|
s.handleConfigLoaded({ snapshot: true })
|
|
|
|
// Config must not revert — the save is still in flight
|
|
expect(s.config.snapshot).toBe(false)
|
|
})
|
|
|
|
it("discardConfig restores server state", () => {
|
|
const s = new ConfigState()
|
|
s.handleConfigLoaded({ snapshot: true, username: "alice" })
|
|
s.updateConfig({ snapshot: false })
|
|
expect(s.dirty).toBe(true)
|
|
|
|
s.discardConfig()
|
|
|
|
expect(s.config.snapshot).toBe(true)
|
|
expect(s.dirty).toBe(false)
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Issue #9527: clearing an agent model override must unset it, not repopulate
|
|
// -------------------------------------------------------------------------
|
|
describe("clearing an agent model override (issue #9527)", () => {
|
|
it("keeps null in the draft so the backend receives a delete sentinel", () => {
|
|
const s = new ConfigState()
|
|
s.handleConfigLoaded({ agent: { explore: { model: "anthropic/claude-sonnet-4-20250514" } } })
|
|
|
|
// User clears the Model Override field. ModeEditView now sends `null`
|
|
// instead of `undefined` (the fix). null is the delete sentinel that
|
|
// patchJsonc maps to jsonc-parser's remove operation.
|
|
s.updateConfig({ agent: { explore: { model: null } } })
|
|
|
|
// Optimistic UI: stripNulls removes the key so the field renders empty.
|
|
expect(s.config.agent?.explore?.model).toBeUndefined()
|
|
expect(s.dirty).toBe(true)
|
|
|
|
// Draft must retain the null so it survives JSON.stringify on the wire
|
|
// and reaches patchJsonc as an explicit delete.
|
|
expect(s.draft.agent?.explore?.model).toBeNull()
|
|
expect(JSON.parse(JSON.stringify(s.draft))).toEqual({
|
|
agent: { explore: { model: null } },
|
|
})
|
|
})
|
|
|
|
it("undefined (the old buggy behavior) is dropped by JSON.stringify", () => {
|
|
// Reproduction of the pre-fix bug: sending `undefined` results in an
|
|
// empty patch on the wire, so the backend never deletes the override
|
|
// and the next configUpdated pushes the stale model back into the UI.
|
|
const draft = { agent: { explore: { model: undefined } } }
|
|
expect(JSON.parse(JSON.stringify(draft))).toEqual({ agent: { explore: {} } })
|
|
})
|
|
|
|
it("confirms the save and drops the draft once the backend acks", () => {
|
|
const s = new ConfigState()
|
|
s.handleConfigLoaded({ agent: { explore: { model: "anthropic/claude-sonnet-4-20250514" } } })
|
|
s.updateConfig({ agent: { explore: { model: null } } })
|
|
s.saveConfig()
|
|
|
|
// Backend removed the override and pushes the stripped config back.
|
|
s.handleConfigUpdated({ agent: { explore: {} } })
|
|
|
|
expect(s.config.agent?.explore?.model).toBeUndefined()
|
|
expect(s.dirty).toBe(false)
|
|
expect(s.saving).toBe(false)
|
|
expect(Object.keys(s.draft).length).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe("clearing an agent variant override", () => {
|
|
it("keeps null in the draft so the backend receives a delete sentinel", () => {
|
|
const s = new ConfigState()
|
|
s.handleConfigLoaded({
|
|
agent: {
|
|
explore: {
|
|
model: "kilo/anthropic/claude-sonnet-4-6",
|
|
variant: "high",
|
|
},
|
|
},
|
|
})
|
|
|
|
s.updateConfig({ agent: { explore: { variant: null } } })
|
|
|
|
expect(s.config.agent?.explore?.variant).toBeUndefined()
|
|
expect(s.dirty).toBe(true)
|
|
expect(s.draft.agent?.explore?.variant).toBeNull()
|
|
expect(JSON.parse(JSON.stringify(s.draft))).toEqual({
|
|
agent: { explore: { variant: null } },
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("agent permission patches", () => {
|
|
it("merges nested per-agent permission patches into existing rules", () => {
|
|
const s = new ConfigState()
|
|
s.handleConfigLoaded({
|
|
agent: {
|
|
reviewer: {
|
|
permission: {
|
|
read: "allow",
|
|
edit: "deny",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
s.updateConfig({ agent: { reviewer: { permission: { bash: "ask" } } } })
|
|
|
|
expect(s.config.agent?.reviewer?.permission).toEqual({
|
|
read: "allow",
|
|
edit: "deny",
|
|
bash: "ask",
|
|
})
|
|
expect(s.draft.agent?.reviewer?.permission).toEqual({ bash: "ask" })
|
|
})
|
|
|
|
it("keeps nested permission delete sentinels in the draft", () => {
|
|
const s = new ConfigState()
|
|
s.handleConfigLoaded({
|
|
agent: {
|
|
docs: {
|
|
permission: {
|
|
edit: { "*": "deny", "**/*.md": "allow" },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
s.updateConfig({ agent: { docs: { permission: { edit: { "**/*.md": null } } } } })
|
|
|
|
expect(s.config.agent?.docs?.permission).toEqual({ edit: { "*": "deny" } })
|
|
expect(s.draft.agent?.docs?.permission).toEqual({ edit: { "**/*.md": null } })
|
|
expect(JSON.parse(JSON.stringify(s.draft))).toEqual({
|
|
agent: { docs: { permission: { edit: { "**/*.md": null } } } },
|
|
})
|
|
})
|
|
|
|
it("keeps tool-level permission delete sentinels in the draft", () => {
|
|
const s = new ConfigState()
|
|
s.handleConfigLoaded({
|
|
agent: {
|
|
reviewer: {
|
|
permission: {
|
|
read: "allow",
|
|
bash: "deny",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
s.updateConfig({ agent: { reviewer: { permission: { bash: null } } } })
|
|
|
|
expect(s.config.agent?.reviewer?.permission).toEqual({ read: "allow" })
|
|
expect(s.draft.agent?.reviewer?.permission).toEqual({ bash: null })
|
|
expect(JSON.parse(JSON.stringify(s.draft))).toEqual({
|
|
agent: { reviewer: { permission: { bash: null } } },
|
|
})
|
|
})
|
|
})
|
|
})
|