From eae09d45767a313a8a90112db9a27d8ae6b5b9d1 Mon Sep 17 00:00:00 2001 From: Szasz Attila Date: Tue, 23 Jun 2026 21:46:23 +0300 Subject: [PATCH] fix(settings): persist per-model visibility and sibling selector state (#1700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(settings): persist per-model visibility and sibling selector state The server-side settings sanitizer only allowlisted favoriteModels and recentModels, so hiddenModels, collapsedModelProviders, recentAgents, and recentEfforts were stripped on every write to settings.json — per-model visibility and collapsed-provider state silently reset on every container redeploy or settings reload. Add the four missing fields to sanitizeSettingsUpdate: - hiddenModels: sanitizeModelRefs(..., 1024) — same shape as favoriteModels; 1024 covers dense multi-provider setups while bounding persistence/memory. - collapsedModelProviders: normalizeStringArray with Array.isArray gate (matches usageDropdownProviders). - recentAgents: normalizeStringArray (Array per ui-store). - recentEfforts: new sanitizeRecentEfforts validating Record (shape confirmed in ui-store + addRecentEffort action); trims/dedupes keys and variants, caps at 128 keys x 5 variants/key (5 matches client slice). No ui-store version bump or migration: zustand's default merge spreads persisted state over defaults, so missing fields fall back to [] / {} until the next toggle. favoriteModels and recentModels are untouched. Tests: 8 new cases in settings-helpers.test.js using the real sanitizeModelRefs / normalizeStringArray — round-trips, empty-[] parity with favoriteModels, garbage rejection, and a full-payload regression test. * fix: sync model selector settings --------- Co-authored-by: Bohdan Triapitsyn --- packages/ui/src/lib/desktop.ts | 4 + packages/ui/src/lib/modelPrefsAutoSave.ts | 90 ++++++++-- packages/ui/src/lib/persistence.test.ts | 90 +++++++++- packages/ui/src/lib/persistence.ts | 98 ++++++++++- .../server/lib/opencode/settings-helpers.js | 54 ++++++ .../lib/opencode/settings-helpers.test.js | 154 ++++++++++++++++++ 6 files changed, 464 insertions(+), 26 deletions(-) diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index d63e4277..f4793e46 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -158,7 +158,11 @@ export type DesktopSettings = { shortcutOverrides?: Record; favoriteModels?: Array<{ providerID: string; modelID: string }>; + hiddenModels?: Array<{ providerID: string; modelID: string }>; + collapsedModelProviders?: string[]; recentModels?: Array<{ providerID: string; modelID: string }>; + recentAgents?: string[]; + recentEfforts?: Record; diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side'; gitChangesViewMode?: 'flat' | 'tree'; directoryShowHidden?: boolean; diff --git a/packages/ui/src/lib/modelPrefsAutoSave.ts b/packages/ui/src/lib/modelPrefsAutoSave.ts index 3af5d116..e9d8ecbf 100644 --- a/packages/ui/src/lib/modelPrefsAutoSave.ts +++ b/packages/ui/src/lib/modelPrefsAutoSave.ts @@ -3,6 +3,14 @@ import { updateDesktopSettings } from '@/lib/persistence'; import { isVSCodeRuntime } from '@/lib/desktop'; type ModelRef = { providerID: string; modelID: string }; +type ModelPrefsPayload = { + favoriteModels: ModelRef[]; + hiddenModels: ModelRef[]; + collapsedModelProviders: string[]; + recentModels: ModelRef[]; + recentAgents: string[]; + recentEfforts: Record; +}; const refsEqual = (a: ModelRef[], b: ModelRef[]): boolean => { if (a === b) return true; @@ -14,6 +22,52 @@ const refsEqual = (a: ModelRef[], b: ModelRef[]): boolean => { return true; }; +const stringsEqual = (a: string[], b: string[]): boolean => { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +}; + +const recentEffortsEqual = (a: Record, b: Record): boolean => { + if (a === b) return true; + const aKeys = Object.keys(a); + if (aKeys.length !== Object.keys(b).length) return false; + return aKeys.every((key) => Array.isArray(b[key]) && stringsEqual(a[key], b[key])); +}; + +const snapshotModelPrefs = (): ModelPrefsPayload => { + const state = useUIStore.getState(); + return { + favoriteModels: state.favoriteModels, + hiddenModels: state.hiddenModels, + collapsedModelProviders: state.collapsedModelProviders, + recentModels: state.recentModels, + recentAgents: state.recentAgents, + recentEfforts: state.recentEfforts, + }; +}; + +const modelPrefsEqual = (a: ModelPrefsPayload, b: ModelPrefsPayload): boolean => ( + refsEqual(a.favoriteModels, b.favoriteModels) && + refsEqual(a.hiddenModels, b.hiddenModels) && + stringsEqual(a.collapsedModelProviders, b.collapsedModelProviders) && + refsEqual(a.recentModels, b.recentModels) && + stringsEqual(a.recentAgents, b.recentAgents) && + recentEffortsEqual(a.recentEfforts, b.recentEfforts) +); + +const cloneModelPrefs = (prefs: ModelPrefsPayload): ModelPrefsPayload => ({ + favoriteModels: prefs.favoriteModels.slice(), + hiddenModels: prefs.hiddenModels.slice(), + collapsedModelProviders: prefs.collapsedModelProviders.slice(), + recentModels: prefs.recentModels.slice(), + recentAgents: prefs.recentAgents.slice(), + recentEfforts: Object.fromEntries(Object.entries(prefs.recentEfforts).map(([key, variants]) => [key, variants.slice()])), +}); + export const startModelPrefsAutoSave = () => { if (typeof window === 'undefined') { return () => {}; @@ -23,26 +77,18 @@ export const startModelPrefsAutoSave = () => { } let timer: number | null = null; - let lastSent: { favoriteModels: ModelRef[]; recentModels: ModelRef[] } | null = null; + let lastSent: ModelPrefsPayload | null = null; let didSkipInitial = false; const flush = () => { timer = null; - const state = useUIStore.getState(); - const payload = { favoriteModels: state.favoriteModels, recentModels: state.recentModels }; + const payload = snapshotModelPrefs(); - if ( - lastSent && - refsEqual(lastSent.favoriteModels, payload.favoriteModels) && - refsEqual(lastSent.recentModels, payload.recentModels) - ) { + if (lastSent && modelPrefsEqual(lastSent, payload)) { return; } - lastSent = { - favoriteModels: payload.favoriteModels.slice(), - recentModels: payload.recentModels.slice(), - }; + lastSent = cloneModelPrefs(payload); void updateDesktopSettings(payload).catch(() => {}); }; @@ -59,9 +105,23 @@ export const startModelPrefsAutoSave = () => { }; const unsubscribe = useUIStore.subscribe((state, prevState) => { - const next = { favoriteModels: state.favoriteModels, recentModels: state.recentModels }; - const prev = { favoriteModels: prevState.favoriteModels, recentModels: prevState.recentModels }; - if (refsEqual(next.favoriteModels, prev.favoriteModels) && refsEqual(next.recentModels, prev.recentModels)) { + const next = { + favoriteModels: state.favoriteModels, + hiddenModels: state.hiddenModels, + collapsedModelProviders: state.collapsedModelProviders, + recentModels: state.recentModels, + recentAgents: state.recentAgents, + recentEfforts: state.recentEfforts, + }; + const prev = { + favoriteModels: prevState.favoriteModels, + hiddenModels: prevState.hiddenModels, + collapsedModelProviders: prevState.collapsedModelProviders, + recentModels: prevState.recentModels, + recentAgents: prevState.recentAgents, + recentEfforts: prevState.recentEfforts, + }; + if (modelPrefsEqual(next, prev)) { return; } schedule(); diff --git a/packages/ui/src/lib/persistence.test.ts b/packages/ui/src/lib/persistence.test.ts index 5f11b95f..fa568932 100644 --- a/packages/ui/src/lib/persistence.test.ts +++ b/packages/ui/src/lib/persistence.test.ts @@ -2,11 +2,15 @@ import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; import type { RuntimeAPIs, SettingsPayload } from '@/lib/api/types'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { applyPersistedHomeDirectoryToWindow, updateDesktopSettings } from './persistence'; +import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave'; +import { useUIStore } from '@/stores/useUIStore'; +import { applyPersistedHomeDirectoryToWindow, syncDesktopSettings, updateDesktopSettings } from './persistence'; type TestWindow = { __OPENCHAMBER_HOME__?: string; dispatchEvent: (event: Event) => boolean; + setTimeout: typeof setTimeout; + clearTimeout: typeof clearTimeout; }; let createdWindow = false; @@ -48,22 +52,42 @@ const getWindow = (): TestWindow => { } const testWindow = window as unknown as Partial; testWindow.dispatchEvent ??= () => true; + testWindow.setTimeout ??= setTimeout; + testWindow.clearTimeout ??= clearTimeout; ensureLocalStorage(); return testWindow as TestWindow; }; const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); -const registerSettingsSave = (save: (changes: Partial) => Promise): void => { +const registerSettingsApi = ( + save: (changes: Partial) => Promise, + load: () => Promise<{ settings: SettingsPayload; source: 'web' | 'vscode' }> = async () => ({ settings: {}, source: 'web' }), +): void => { registerRuntimeAPIs({ runtime: { platform: 'web', isDesktop: false, isVSCode: false }, settings: { - load: async () => ({ settings: {}, source: 'web' }), + load, save, }, } as unknown as RuntimeAPIs); }; +const registerSettingsSave = (save: (changes: Partial) => Promise): void => { + registerSettingsApi(save); +}; + +const resetModelPrefsState = (): void => { + useUIStore.setState({ + favoriteModels: [], + hiddenModels: [], + collapsedModelProviders: [], + recentModels: [], + recentAgents: [], + recentEfforts: {}, + }); +}; + afterAll(() => { registerRuntimeAPIs(null); if (createdWindow) { @@ -100,6 +124,7 @@ describe('updateDesktopSettings', () => { beforeEach(() => { getWindow(); registerRuntimeAPIs(null); + resetModelPrefsState(); }); test('waits for the debounced settings save to finish before resolving', async () => { @@ -170,4 +195,63 @@ describe('updateDesktopSettings', () => { expect(firstResolved).toBe(true); expect(secondResolved).toBe(true); }); + + test('applies model selector settings from server settings', async () => { + getWindow(); + const settings = { + favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4' }], + hiddenModels: [{ providerID: 'openai', modelID: 'gpt-5' }], + collapsedModelProviders: ['anthropic', 'openai'], + recentModels: [{ providerID: 'google', modelID: 'gemini-pro' }], + recentAgents: ['build', 'plan'], + recentEfforts: { 'anthropic/claude-haiku-4': ['high', 'default'] }, + } satisfies SettingsPayload; + registerSettingsApi(async () => ({}), async () => ({ settings, source: 'web' })); + + await syncDesktopSettings(); + + const state = useUIStore.getState(); + expect(state.favoriteModels).toEqual(settings.favoriteModels); + expect(state.hiddenModels).toEqual(settings.hiddenModels); + expect(state.collapsedModelProviders).toEqual(settings.collapsedModelProviders); + expect(state.recentModels).toEqual(settings.recentModels); + expect(state.recentAgents).toEqual(settings.recentAgents); + expect(state.recentEfforts).toEqual(settings.recentEfforts); + }); + + test('autosaves all model selector settings fields', async () => { + getWindow(); + const saveCalls: Array> = []; + registerSettingsSave(async (changes) => { + saveCalls.push(changes); + return changes as SettingsPayload; + }); + const stop = startModelPrefsAutoSave(); + + try { + useUIStore.setState({ favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4' }] }); + await delay(20); + useUIStore.setState({ + hiddenModels: [{ providerID: 'openai', modelID: 'gpt-5' }], + collapsedModelProviders: ['openai'], + recentModels: [{ providerID: 'google', modelID: 'gemini-pro' }], + recentAgents: ['build'], + recentEfforts: { 'openai/gpt-5': ['low'] }, + }); + + await delay(1500); + + expect(saveCalls).toHaveLength(1); + expect(saveCalls[0]).toEqual({ + favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4' }], + hiddenModels: [{ providerID: 'openai', modelID: 'gpt-5' }], + collapsedModelProviders: ['openai'], + recentModels: [{ providerID: 'google', modelID: 'gemini-pro' }], + recentAgents: ['build'], + recentEfforts: { 'openai/gpt-5': ['low'] }, + }); + } finally { + stop(); + } + }); }); diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 076e02a7..90cbae22 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -203,6 +203,42 @@ const areStringRecordsEqual = (left: Record, right: Record right[key] === value); }; +const areModelRefsEqual = ( + left: Array<{ providerID: string; modelID: string }>, + right: Array<{ providerID: string; modelID: string }>, +): boolean => ( + left.length === right.length && + left.every((item, idx) => item.providerID === right[idx]?.providerID && item.modelID === right[idx]?.modelID) +); + +const areStringArraysEqual = (left: string[], right: string[]): boolean => ( + left.length === right.length && left.every((value, idx) => value === right[idx]) +); + +const sanitizeStringArray = (value: unknown): string[] | undefined => { + if (!Array.isArray(value)) return undefined; + return Array.from(new Set(value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0))); +}; + +const sanitizeRecentEfforts = (value: unknown): Record | undefined => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const result: Record = {}; + for (const [key, variants] of Object.entries(value)) { + if (!key || !Array.isArray(variants)) continue; + const sanitized = sanitizeStringArray(variants); + if (sanitized && sanitized.length > 0) { + result[key] = sanitized.slice(0, 5); + } + } + return Object.keys(result).length > 0 ? result : undefined; +}; + +const areRecentEffortsEqual = (left: Record, right: Record): boolean => { + const leftKeys = Object.keys(left); + if (leftKeys.length !== Object.keys(right).length) return false; + return leftKeys.every((key) => Array.isArray(right[key]) && areStringArraysEqual(left[key], right[key])); +}; + const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/; const normalizeIconBackground = (value: unknown): string | null => { @@ -601,24 +637,50 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { if (Array.isArray(settings.favoriteModels)) { const current = store.favoriteModels; const next = settings.favoriteModels; - const same = - current.length === next.length && - current.every((item, idx) => item.providerID === next[idx]?.providerID && item.modelID === next[idx]?.modelID); - if (!same) { + if (!areModelRefsEqual(current, next)) { useUIStore.setState({ favoriteModels: next }); } } + if (Array.isArray(settings.hiddenModels)) { + const current = store.hiddenModels; + const next = settings.hiddenModels; + if (!areModelRefsEqual(current, next)) { + useUIStore.setState({ hiddenModels: next }); + } + } + + if (Array.isArray(settings.collapsedModelProviders)) { + const current = store.collapsedModelProviders; + const next = settings.collapsedModelProviders; + if (!areStringArraysEqual(current, next)) { + useUIStore.setState({ collapsedModelProviders: next }); + } + } + if (Array.isArray(settings.recentModels)) { const current = store.recentModels; const next = settings.recentModels; - const same = - current.length === next.length && - current.every((item, idx) => item.providerID === next[idx]?.providerID && item.modelID === next[idx]?.modelID); - if (!same) { + if (!areModelRefsEqual(current, next)) { useUIStore.setState({ recentModels: next }); } } + + if (Array.isArray(settings.recentAgents)) { + const current = store.recentAgents; + const next = settings.recentAgents; + if (!areStringArraysEqual(current, next)) { + useUIStore.setState({ recentAgents: next }); + } + } + + if (settings.recentEfforts && typeof settings.recentEfforts === 'object') { + const current = store.recentEfforts; + const next = settings.recentEfforts; + if (!areRecentEffortsEqual(current, next)) { + useUIStore.setState({ recentEfforts: next }); + } + } if (typeof settings.diffLayoutPreference === 'string' && (settings.diffLayoutPreference === 'dynamic' || settings.diffLayoutPreference === 'inline' || settings.diffLayoutPreference === 'side-by-side')) { if (settings.diffLayoutPreference !== store.diffLayoutPreference) { @@ -1047,10 +1109,30 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { result.favoriteModels = favoriteModels; } + const hiddenModels = sanitizeModelRefs(candidate.hiddenModels, 1024); + if (hiddenModels) { + result.hiddenModels = hiddenModels; + } + + const collapsedModelProviders = sanitizeStringArray(candidate.collapsedModelProviders); + if (collapsedModelProviders) { + result.collapsedModelProviders = collapsedModelProviders; + } + const recentModels = sanitizeModelRefs(candidate.recentModels, 16); if (recentModels) { result.recentModels = recentModels; } + + const recentAgents = sanitizeStringArray(candidate.recentAgents); + if (recentAgents) { + result.recentAgents = recentAgents; + } + + const recentEfforts = sanitizeRecentEfforts(candidate.recentEfforts); + if (recentEfforts) { + result.recentEfforts = recentEfforts; + } if ( typeof candidate.diffLayoutPreference === 'string' && (candidate.diffLayoutPreference === 'dynamic' diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 83ace4ae..fd995069 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -26,6 +26,9 @@ export const createSettingsHelpers = (dependencies) => { const SHORTCUT_OVERRIDE_VALUE_MAX_LENGTH = 128; const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']); const MOBILE_KEYBOARD_MODE_VALUES = new Set(['native', 'resize-content']); + const HIDDEN_MODELS_MAX = 1024; + const RECENT_EFFORTS_MAX_KEYS = 128; + const RECENT_EFFORTS_MAX_VARIANTS_PER_KEY = 5; const sanitizeShortcutOverrides = (value) => { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -41,6 +44,35 @@ export const createSettingsHelpers = (dependencies) => { return result; }; + const sanitizeRecentEfforts = (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + const result = {}; + const seenKeys = new Set(); + let count = 0; + for (const [rawKey, rawVariants] of Object.entries(value)) { + const key = typeof rawKey === 'string' ? rawKey.trim() : ''; + if (!key || seenKeys.has(key)) continue; + if (!Array.isArray(rawVariants)) continue; + const variants = []; + const seenVariants = new Set(); + for (const rawVariant of rawVariants) { + const variant = typeof rawVariant === 'string' ? rawVariant.trim() : ''; + if (!variant || seenVariants.has(variant)) continue; + seenVariants.add(variant); + variants.push(variant); + if (variants.length >= RECENT_EFFORTS_MAX_VARIANTS_PER_KEY) break; + } + if (variants.length === 0) continue; + seenKeys.add(key); + result[key] = variants; + count += 1; + if (count >= RECENT_EFFORTS_MAX_KEYS) break; + } + return count > 0 ? result : null; + }; + const normalizePwaAppName = (value, fallback = '') => { if (typeof value !== 'string') { return fallback; @@ -474,6 +506,28 @@ export const createSettingsHelpers = (dependencies) => { if (recentModels) { result.recentModels = recentModels; } + + // Cap at 1024: users with several providers (anthropic, openai, google, + // bedrock, azure, etc.) each exposing dozens-to-hundreds of models can + // exceed 256 hidden entries quickly. 1024 covers dense multi-provider + // setups while still bounding persistence/memory. + const hiddenModels = sanitizeModelRefs(candidate.hiddenModels, HIDDEN_MODELS_MAX); + if (hiddenModels) { + result.hiddenModels = hiddenModels; + } + + if (Array.isArray(candidate.collapsedModelProviders)) { + result.collapsedModelProviders = normalizeStringArray(candidate.collapsedModelProviders); + } + + if (Array.isArray(candidate.recentAgents)) { + result.recentAgents = normalizeStringArray(candidate.recentAgents); + } + + const recentEfforts = sanitizeRecentEfforts(candidate.recentEfforts); + if (recentEfforts) { + result.recentEfforts = recentEfforts; + } if (typeof candidate.diffLayoutPreference === 'string') { const mode = candidate.diffLayoutPreference.trim(); if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') { diff --git a/packages/web/server/lib/opencode/settings-helpers.test.js b/packages/web/server/lib/opencode/settings-helpers.test.js index 943c2ba1..37fe4cf6 100644 --- a/packages/web/server/lib/opencode/settings-helpers.test.js +++ b/packages/web/server/lib/opencode/settings-helpers.test.js @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createSettingsHelpers } from './settings-helpers.js'; +import { createSettingsNormalizationRuntime } from './settings-normalization-runtime.js'; const createTestHelpers = () => createSettingsHelpers({ normalizePathForPersistence: (value) => value, @@ -20,6 +21,42 @@ const createTestHelpers = () => createSettingsHelpers({ sanitizeProjects: () => undefined, }); +const createTestHelpersWithRealSanitizers = () => { + const runtime = createSettingsNormalizationRuntime({ + os: { homedir: () => '/home/testuser' }, + path: { + resolve: (...args) => args[args.length - 1], + sep: '/', + dirname: (p) => p.split('/').slice(0, -1).join('/') || '/', + }, + processLike: { platform: 'linux', env: {} }, + realpathSync: (p) => p, + tunnelBootstrapTtlDefaultMs: 600000, + tunnelBootstrapTtlMinMs: 60000, + tunnelBootstrapTtlMaxMs: 3600000, + tunnelSessionTtlDefaultMs: 86400000, + tunnelSessionTtlMinMs: 3600000, + tunnelSessionTtlMaxMs: 604800000, + }); + return createSettingsHelpers({ + normalizePathForPersistence: (value) => value, + normalizeDirectoryPath: (value) => value, + normalizeTunnelBootstrapTtlMs: (value) => value, + normalizeTunnelSessionTtlMs: (value) => value, + normalizeTunnelProvider: (value) => value, + normalizeTunnelMode: (value) => value, + normalizeOptionalPath: (value) => value, + normalizeManagedRemoteTunnelHostname: (value) => value, + normalizeManagedRemoteTunnelPresets: () => undefined, + normalizeManagedRemoteTunnelPresetTokens: () => undefined, + sanitizeTypographySizesPartial: () => undefined, + normalizeStringArray: runtime.normalizeStringArray, + sanitizeModelRefs: runtime.sanitizeModelRefs, + sanitizeSkillCatalogs: () => undefined, + sanitizeProjects: () => undefined, + }); +}; + describe('settings helpers', () => { it('accepts messageStreamTransport as a persisted shared setting', () => { const helpers = createTestHelpers(); @@ -188,4 +225,121 @@ describe('settings helpers', () => { else delete process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON; } }); + + describe('previously-dropped model selector persistence fields', () => { + it('round-trips hiddenModels through the sanitizer', () => { + const helpers = createTestHelpersWithRealSanitizers(); + const input = [ + { providerID: 'anthropic', modelID: 'claude-opus-4' }, + { providerID: 'openai', modelID: 'gpt-5' }, + ]; + + expect(helpers.sanitizeSettingsUpdate({ hiddenModels: input })).toEqual({ + hiddenModels: input, + }); + }); + + it('handles empty hiddenModels the same way as empty favoriteModels', () => { + const helpers = createTestHelpersWithRealSanitizers(); + + const hiddenResult = helpers.sanitizeSettingsUpdate({ hiddenModels: [] }); + const favoriteResult = helpers.sanitizeSettingsUpdate({ favoriteModels: [] }); + + expect(hiddenResult.hiddenModels).toEqual([]); + expect(favoriteResult.favoriteModels).toEqual([]); + expect(hiddenResult.hiddenModels).toEqual(favoriteResult.favoriteModels); + }); + + it('round-trips collapsedModelProviders and recentAgents as string arrays', () => { + const helpers = createTestHelpersWithRealSanitizers(); + + expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: ['anthropic', 'openai'] })).toEqual({ + collapsedModelProviders: ['anthropic', 'openai'], + }); + expect(helpers.sanitizeSettingsUpdate({ recentAgents: ['build', 'plan'] })).toEqual({ + recentAgents: ['build', 'plan'], + }); + }); + + it('round-trips recentEfforts as a Record', () => { + const helpers = createTestHelpersWithRealSanitizers(); + const input = { + 'anthropic/claude-opus-4': ['high', 'default'], + 'openai/gpt-5': ['low'], + }; + + expect(helpers.sanitizeSettingsUpdate({ recentEfforts: input })).toEqual({ + recentEfforts: input, + }); + }); + + it('rejects garbage hiddenModels input the same way sanitizeModelRefs rejects bad refs', () => { + const helpers = createTestHelpersWithRealSanitizers(); + + expect(helpers.sanitizeSettingsUpdate({ hiddenModels: 'not-an-array' })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ hiddenModels: null })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ hiddenModels: 123 })).toEqual({}); + expect( + helpers.sanitizeSettingsUpdate({ + hiddenModels: [ + { providerID: 'anthropic' }, + { modelID: 'gpt-5' }, + 'not-an-object', + null, + { providerID: ' ', modelID: 'x' }, + { providerID: 'openai', modelID: '' }, + ], + }) + ).toEqual({ hiddenModels: [] }); + }); + + it('rejects garbage collapsedModelProviders and recentAgents input', () => { + const helpers = createTestHelpersWithRealSanitizers(); + + expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: 'anthropic' })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: null })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ recentAgents: 42 })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ recentAgents: { build: 1 } })).toEqual({}); + }); + + it('rejects garbage recentEfforts input', () => { + const helpers = createTestHelpersWithRealSanitizers(); + + expect(helpers.sanitizeSettingsUpdate({ recentEfforts: 'not-an-object' })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ recentEfforts: [] })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ recentEfforts: null })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': 'high' } })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { '': ['high'] } })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': [] } })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': [123, ''] } })).toEqual({}); + }); + + it('survives a full settings.json payload containing all four previously-dropped fields (regression)', () => { + const helpers = createTestHelpersWithRealSanitizers(); + const payload = { + themeId: 'default', + hiddenModels: [ + { providerID: 'anthropic', modelID: 'claude-opus-4' }, + { providerID: 'openai', modelID: 'gpt-5' }, + ], + collapsedModelProviders: ['anthropic', 'openai'], + recentAgents: ['build', 'plan'], + recentEfforts: { + 'anthropic/claude-opus-4': ['high', 'default'], + 'openai/gpt-5': ['low'], + }, + favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4' }], + recentModels: [{ providerID: 'openai', modelID: 'gpt-5' }], + }; + + const sanitized = helpers.sanitizeSettingsUpdate(payload); + + expect(sanitized.hiddenModels).toEqual(payload.hiddenModels); + expect(sanitized.collapsedModelProviders).toEqual(payload.collapsedModelProviders); + expect(sanitized.recentAgents).toEqual(payload.recentAgents); + expect(sanitized.recentEfforts).toEqual(payload.recentEfforts); + expect(sanitized.favoriteModels).toEqual(payload.favoriteModels); + expect(sanitized.recentModels).toEqual(payload.recentModels); + }); + }); });