From a61a0bc01988420a89200b321b3f7a118720a065 Mon Sep 17 00:00:00 2001 From: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:06:17 -0700 Subject: [PATCH] fix(desktop): prevent MoA autosave defaults explosion from half-filled slots Three fixes in model-settings.tsx: 1. Filter incomplete slots before autosave: sanitizeMoaRefsForSave() strips reference slots with empty model before any autosave (the 600ms debounce was sending half-filled provider-but-no-model slots to the backend, where _clean_slot rejects them and _normalize_preset falls back to hardcoded defaults). Presets with zero valid refs keep their empty reference_models array rather than being silently dropped. The aggregator slot is also sanitized when its model is empty. 2. Add withActive() to MoA provider dropdowns: the reference and aggregator provider Selects filtered to authenticated-only, so unauthenticated current values (e.g. openai-codex) rendered blank. Mirror the existing pattern from the model Select. 3. Add generation counter to scheduleMoaSave(): stale save responses could overwrite newer state. Bump a counter on each save and skip setMoa/setError if a newer save was scheduled in the meantime. --- .../src/app/settings/model-settings.tsx | 77 +++++++++++++++---- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index fa7d15a5d1ed..fc761c97dbdd 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -317,6 +317,38 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { [] ) + // Guard against stale save responses overwriting newer state. + const moaSaveGeneration = useRef(0) + + // Strip slots with an empty model from every preset before autosave, so a + // half-filled slot (provider selected but no model yet) is never sent to the + // backend. Without this guard, _clean_slot rejects the empty-model slot and + // _normalize_preset falls back to hardcoded defaults. Both reference and + // aggregator slots are sanitized. Presets keep their empty reference_models + // array rather than being dropped. + const sanitizeMoaRefsForSave = useCallback((config: MoaConfigResponse): MoaConfigResponse => { + const presets: MoaConfigResponse['presets'] = {} + let changed = false + + for (const [name, preset] of Object.entries(config.presets)) { + const refs = preset.reference_models.filter(slot => slot.provider.trim() && slot.model.trim()) + if (refs.length !== preset.reference_models.length) { + changed = true + } + const agg = preset.aggregator + const aggValid = agg && agg.provider.trim() && agg.model.trim() + const cleanAgg = aggValid ? agg : { provider: agg?.provider ?? '', model: agg?.model ?? '' } + + presets[name] = { + ...preset, + reference_models: refs, + aggregator: cleanAgg + } + } + + return changed ? { ...config, presets } : config + }, []) + // Quiet debounced persist for inline MoA edits — mirrors the config page's // autosave so slot/aggregator tweaks save themselves, matching the // preset-level ops (set default / add / delete) that already persist on @@ -326,12 +358,23 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { window.clearTimeout(moaSaveTimer.current) } + const generation = moaSaveGeneration.current + 1 + moaSaveGeneration.current = generation + moaSaveTimer.current = window.setTimeout(() => { - void saveMoaModels(next) - .then(setMoa) - .catch(err => setError(err instanceof Error ? err.message : String(err))) + void saveMoaModels(sanitizeMoaRefsForSave(next)) + .then(saved => { + if (moaSaveGeneration.current === generation) { + setMoa(saved) + } + }) + .catch(err => { + if (moaSaveGeneration.current === generation) { + setError(err instanceof Error ? err.message : String(err)) + } + }) }, 600) - }, []) + }, [sanitizeMoaRefsForSave]) const updateMoaPreset = useCallback( (updater: (preset: NonNullable) => NonNullable) => { @@ -991,11 +1034,14 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { - {moaSlotProviderOptions.map(provider => ( - - {provider.name} - - ))} + {withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), slot.provider).map(slug => { + const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug) + return ( + + {provider?.name || slug} + + ) + })}