diff --git a/client/src/utils/__tests__/getDefaultModelSpec.test.ts b/client/src/utils/__tests__/getDefaultModelSpec.test.ts index 37c9af163c..7a8fe01818 100644 --- a/client/src/utils/__tests__/getDefaultModelSpec.test.ts +++ b/client/src/utils/__tests__/getDefaultModelSpec.test.ts @@ -105,7 +105,7 @@ describe('getDefaultModelSpec', () => { it('keeps the last selected spec before applying the soft default', () => { const lastSpec = createModelSpec('last-spec'); const softSpec = createModelSpec('soft-spec', { softDefault: true }); - localStorage.setItem(LocalStorageKeys.LAST_SPEC, lastSpec.name); + persistAppliedSpec(lastSpec); const result = getDefaultModelSpec(createStartupConfig([softSpec, lastSpec])); @@ -114,10 +114,7 @@ describe('getDefaultModelSpec', () => { it('does not apply the soft default when a prior model selection exists', () => { const softSpec = createModelSpec('soft-spec', { softDefault: true }); - localStorage.setItem( - LocalStorageKeys.LAST_MODEL, - JSON.stringify({ [EModelEndpoint.openAI]: 'gpt-4o' }), - ); + persistEphemeralSelection(EModelEndpoint.openAI, 'gpt-4o'); const result = getDefaultModelSpec(createStartupConfig([softSpec]), fullEndpointsConfig); @@ -126,7 +123,7 @@ describe('getDefaultModelSpec', () => { it('does not apply the soft default when a prior agent selection exists', () => { const softSpec = createModelSpec('soft-spec', { softDefault: true }); - localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, 'agent_123'); + persistAgentSelection('agent_123'); const result = getDefaultModelSpec(createStartupConfig([softSpec]), fullEndpointsConfig); @@ -136,7 +133,7 @@ describe('getDefaultModelSpec', () => { it('keeps hard admin defaults ahead of user history and soft defaults', () => { const hardSpec = createModelSpec('hard-spec', { default: true }); const softSpec = createModelSpec('soft-spec', { softDefault: true }); - localStorage.setItem(LocalStorageKeys.LAST_SPEC, softSpec.name); + persistAppliedSpec(softSpec); const result = getDefaultModelSpec(createStartupConfig([softSpec, hardSpec])); @@ -231,7 +228,7 @@ describe('getDefaultModelSpec', () => { softDefault: true, preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_soft' }, } as Partial); - localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, 'agent_soft'); + persistAppliedSpec(softAgentSpec); const result = getDefaultModelSpec( createStartupConfig([softAgentSpec], { prioritize: false }), @@ -242,10 +239,7 @@ describe('getDefaultModelSpec', () => { }); it('treats a stored model matching the soft default preset as residue', () => { - localStorage.setItem( - LocalStorageKeys.LAST_MODEL, - JSON.stringify({ [softSpec.preset.endpoint as string]: softSpec.preset.model }), - ); + persistAppliedSpec(softSpec); const result = getDefaultModelSpec( createStartupConfig([otherSpec, softSpec], { prioritize: false }), @@ -266,7 +260,7 @@ describe('getDefaultModelSpec', () => { expect(result).toEqual({ softDefault: softSpec }); }); - it('does not re-arm from viewing an old soft conversation after an ephemeral pick', () => { + it('re-arms after viewing the soft conversation, even when an ephemeral pick lingers', () => { persistEphemeralSelection(EModelEndpoint.anthropic, 'claude-sonnet-4-6'); persistAppliedSpec(softSpec, 'a8b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d'); @@ -275,10 +269,10 @@ describe('getDefaultModelSpec', () => { fullEndpointsConfig, ); - expect(result).toBeUndefined(); + expect(result).toEqual({ softDefault: softSpec }); }); - it('does not re-arm from viewing an old soft conversation after an agent pick', () => { + it('re-arms after viewing the soft conversation, even when an agent pick lingers', () => { localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, 'agent_abc'); persistAppliedSpec(softSpec, 'a8b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d'); @@ -287,7 +281,7 @@ describe('getDefaultModelSpec', () => { fullEndpointsConfig, ); - expect(result).toBeUndefined(); + expect(result).toEqual({ softDefault: softSpec }); }); it('yields when a different agent is stored than the soft default agent spec', () => { @@ -295,7 +289,7 @@ describe('getDefaultModelSpec', () => { softDefault: true, preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_soft' }, } as Partial); - localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, 'agent_other'); + persistAgentSelection('agent_other'); const result = getDefaultModelSpec( createStartupConfig([softAgentSpec], { prioritize: false }), @@ -306,6 +300,48 @@ describe('getDefaultModelSpec', () => { }); }); + describe('soft default endpoint outside an added-endpoints allow-list', () => { + // Mirrors a softDefault spec on `bedrock` with `addedEndpoints: [agents, ]`. + // The custom endpoint turns on the ephemeral-options gate, and a model used on + // that endpoint lingers under a different key than the spec preset — which used + // to suppress the soft default and strand New Chat on the unselectable bedrock. + const softSpec = createModelSpec('clickhouse-agent', { + softDefault: true, + preset: { endpoint: 'bedrock', model: 'claude-sonnet-4-6' }, + } as Partial); + const otherSpec = createModelSpec('other-spec'); + const allowListConfig = { addedEndpoints: ['agents', 'ClickHouse'] }; + const allowListEndpoints = { + bedrock: { order: 0 }, + ClickHouse: { order: 1 }, + [EModelEndpoint.agents]: { order: 2 }, + } as TEndpointsConfig; + + it('re-arms on New Chat after viewing the soft conversation last', () => { + writeLastModel('ClickHouse', 'kimi-k2p7-code'); + persistAppliedSpec(softSpec, 'a8b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d'); + + const result = getDefaultModelSpec( + createStartupConfig([otherSpec, softSpec], allowListConfig), + allowListEndpoints, + ); + + expect(result).toEqual({ softDefault: softSpec }); + }); + + it('still yields when a selectable endpoint was the last conversation', () => { + persistAppliedSpec(softSpec, 'a8b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d'); + persistEphemeralSelection('ClickHouse', 'kimi-k2p7-code'); + + const result = getDefaultModelSpec( + createStartupConfig([otherSpec, softSpec], allowListConfig), + allowListEndpoints, + ); + + expect(result).toBeUndefined(); + }); + }); + describe('explicit soft default selection', () => { const softSpec = createModelSpec('soft-spec', { softDefault: true }); const otherSpec = createModelSpec('other-spec'); diff --git a/client/src/utils/__tests__/localStorage.test.ts b/client/src/utils/__tests__/localStorage.test.ts new file mode 100644 index 0000000000..18918ce187 --- /dev/null +++ b/client/src/utils/__tests__/localStorage.test.ts @@ -0,0 +1,29 @@ +import { LocalStorageKeys } from 'librechat-data-provider'; +import { clearAllConversationStorage } from '../localStorage'; + +describe('clearAllConversationStorage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('wipes the selection and conversation state but keeps unrelated keys', () => { + localStorage.setItem(LocalStorageKeys.LAST_SPEC, 'some-spec'); + localStorage.setItem(LocalStorageKeys.LAST_MODEL, JSON.stringify({ openAI: 'gpt-4o' })); + localStorage.setItem(LocalStorageKeys.LAST_TOOLS, JSON.stringify(['web_search'])); + localStorage.setItem( + `${LocalStorageKeys.LAST_CONVO_SETUP}_0`, + JSON.stringify({ spec: 'some-spec' }), + ); + localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, 'agent_1'); + localStorage.setItem('unrelated-key', 'keep-me'); + + clearAllConversationStorage(); + + expect(localStorage.getItem(LocalStorageKeys.LAST_SPEC)).toBeNull(); + expect(localStorage.getItem(LocalStorageKeys.LAST_MODEL)).toBeNull(); + expect(localStorage.getItem(LocalStorageKeys.LAST_TOOLS)).toBeNull(); + expect(localStorage.getItem(`${LocalStorageKeys.LAST_CONVO_SETUP}_0`)).toBeNull(); + expect(localStorage.getItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`)).toBeNull(); + expect(localStorage.getItem('unrelated-key')).toBe('keep-me'); + }); +}); diff --git a/client/src/utils/endpoints.ts b/client/src/utils/endpoints.ts index 665159e246..3143bb1115 100644 --- a/client/src/utils/endpoints.ts +++ b/client/src/utils/endpoints.ts @@ -139,7 +139,7 @@ interface InitiatedTemplateResult { type StoredModelSelection = Pick< t.TConversation, - 'endpoint' | 'model' | 'spec' | 'agent_id' | 'assistant_id' | 'conversationId' + 'endpoint' | 'model' | 'spec' | 'agent_id' | 'assistant_id' >; function hasSelectionValue(value?: string | null): boolean { @@ -160,40 +160,6 @@ function parseStoredModelSelection( } } -function hasStoredPrefixValue(prefix: string, ignoreValue?: string | null): boolean { - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (!key?.startsWith(prefix)) { - continue; - } - - const value = localStorage.getItem(key); - if (hasSelectionValue(value) && value !== ignoreValue) { - return true; - } - } - - return false; -} - -function hasStoredModelValue(softPreset?: t.TModelSpecPreset): boolean { - const storedModelValue = localStorage.getItem(LocalStorageKeys.LAST_MODEL); - if (!storedModelValue) { - return false; - } - - try { - const storedModels = JSON.parse(storedModelValue) as Record; - return Object.entries(storedModels).some( - ([endpoint, model]) => - hasSelectionValue(model) && - !(endpoint === softPreset?.endpoint && model === softPreset?.model), - ); - } catch { - return false; - } -} - export function hasModelSelection(selection?: Partial | null): boolean { if (!selection) { return false; @@ -208,39 +174,6 @@ export function hasModelSelection(selection?: Partial | nu ); } -/** - * Whether localStorage holds a model selection the user actually made. - * State matching what applying `softDefaultSpec` writes is residue of the - * soft default itself, not evidence of a user choice, and is ignored. - */ -function hasStoredModelSelection(softDefaultSpec?: t.TModelSpec): boolean { - const softPreset = softDefaultSpec?.preset; - const lastSpecName = localStorage.getItem(LocalStorageKeys.LAST_SPEC); - if (hasSelectionValue(lastSpecName) && lastSpecName !== softDefaultSpec?.name) { - return true; - } - - if (hasStoredModelValue(softPreset)) { - return true; - } - - if ( - hasStoredPrefixValue(LocalStorageKeys.AGENT_ID_PREFIX, softPreset?.agent_id) || - hasStoredPrefixValue(LocalStorageKeys.ASST_ID_PREFIX, softPreset?.assistant_id) - ) { - return true; - } - - const lastConversationSetup = parseStoredModelSelection( - localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP + '_0'), - ); - if (softDefaultSpec && lastConversationSetup?.spec === softDefaultSpec.name) { - return false; - } - - return hasModelSelection(lastConversationSetup); -} - /** * Whether the selector offers any ephemeral endpoint → model options. * False when the endpoints menu is hidden (`modelSelect` disabled) or only @@ -410,13 +343,16 @@ export function applyModelSpecEphemeralAgent({ } /** - * Gets default model spec from config and user preferences. - * Priority: hard admin default → prior user selection → soft default. - * The soft default yields only to selections the user actually made — its - * stored state counts as the live selection only when set on a new chat, so - * merely viewing an old soft-spec conversation never re-arms it — and it acts - * as the fallback default when no ephemeral endpoint → model options exist. - * Legacy first-spec prioritization remains only when no soft default is configured. + * Resolves the default model spec for a new chat. Priority: hard admin default → + * the most recent conversation's own selection → soft default → legacy first spec. + * + * `LAST_CONVO_SETUP_0` is the single source of truth for prior intent: a setup naming + * the soft spec is the soft default re-arming, any other spec/agent/endpoint is a + * selection to carry forward, and an empty setup is a fresh start (clearing chats + * wipes the selection, so a new chat then falls to the soft default). The soft default + * also wins whenever the selector offers no ephemeral endpoint → model options, so a + * stale agent never strands it. The legacy first-spec fallback applies only when specs + * are prioritized (or the model menu is hidden) and no soft default is configured. */ export function getDefaultModelSpec( startupConfig?: t.TStartupConfig, @@ -433,59 +369,42 @@ export function getDefaultModelSpec( if (!list) { return; } - const defaultSpec = list?.find((spec) => spec.default); - const softDefaultSpec = list?.find((spec) => spec.softDefault); - const lastConversationSetup = parseStoredModelSelection( - localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP + '_0'), - ); - const resolveSoftDefault = (): { softDefault: t.TModelSpec } | undefined => { - if (!softDefaultSpec) { - return; - } - if ( - lastConversationSetup?.spec === softDefaultSpec.name && - lastConversationSetup?.conversationId === Constants.NEW_CONVO - ) { - return { softDefault: softDefaultSpec }; - } - const ephemeralOptions = hasEphemeralModelOptions({ - endpointsConfig, - addedEndpoints, - modelSelect: interfaceConfig?.modelSelect, - }); - if (!ephemeralOptions) { - return { softDefault: softDefaultSpec }; - } - return hasStoredModelSelection(softDefaultSpec) ? undefined : { softDefault: softDefaultSpec }; - }; - if (prioritize === true || !interfaceConfig?.modelSelect) { - const lastSelectedSpecName = localStorage.getItem(LocalStorageKeys.LAST_SPEC); - const lastSelectedSpec = list?.find((spec) => spec.name === lastSelectedSpecName); - if (defaultSpec) { - return { default: defaultSpec }; - } - if (lastSelectedSpec && lastSelectedSpec.name === softDefaultSpec?.name) { - return resolveSoftDefault(); - } - if (lastSelectedSpec) { - return { last: lastSelectedSpec }; - } - if (softDefaultSpec) { - return resolveSoftDefault(); - } - return { default: list?.[0] }; - } else if (defaultSpec) { + + const defaultSpec = list.find((spec) => spec.default); + if (defaultSpec) { return { default: defaultSpec }; } - const lastConversationSpecName = lastConversationSetup?.spec; - if (!hasSelectionValue(lastConversationSpecName)) { - return resolveSoftDefault(); + + const softDefaultSpec = list.find((spec) => spec.softDefault); + const lastSetup = parseStoredModelSelection( + localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP + '_0'), + ); + const lastSpec = hasSelectionValue(lastSetup?.spec) + ? list.find((spec) => spec.name === lastSetup?.spec) + : undefined; + + if (lastSpec && lastSpec.name !== softDefaultSpec?.name) { + return { last: lastSpec }; } - const lastSpec = list?.find((spec) => spec.name === lastConversationSpecName); - if (lastSpec && lastSpec.name === softDefaultSpec?.name) { - return resolveSoftDefault(); + + if (softDefaultSpec) { + if (lastSpec?.name === softDefaultSpec.name) { + return { softDefault: softDefaultSpec }; + } + const yieldsToSelection = + hasModelSelection(lastSetup) && + hasEphemeralModelOptions({ + endpointsConfig, + addedEndpoints, + modelSelect: interfaceConfig?.modelSelect, + }); + return yieldsToSelection ? undefined : { softDefault: softDefaultSpec }; } - return { last: lastSpec }; + + if (prioritize === true || interfaceConfig?.modelSelect !== true) { + return { default: list[0] }; + } + return; } export function getModelSpecPreset(modelSpec?: t.TModelSpec) { diff --git a/client/src/utils/localStorage.ts b/client/src/utils/localStorage.ts index d1c9d1acf1..21fe7cb1aa 100644 --- a/client/src/utils/localStorage.ts +++ b/client/src/utils/localStorage.ts @@ -72,7 +72,10 @@ export function clearAllConversationStorage() { key.startsWith(LocalStorageKeys.TEXT_DRAFT) || key.startsWith(LocalStorageKeys.ASST_ID_PREFIX) || key.startsWith(LocalStorageKeys.AGENT_ID_PREFIX) || - key.startsWith(LocalStorageKeys.LAST_CONVO_SETUP) + key.startsWith(LocalStorageKeys.LAST_CONVO_SETUP) || + key === LocalStorageKeys.LAST_SPEC || + key === LocalStorageKeys.LAST_MODEL || + key === LocalStorageKeys.LAST_TOOLS ) { localStorage.removeItem(key); } diff --git a/e2e/specs/mock/soft-default.spec.ts b/e2e/specs/mock/soft-default.spec.ts index 41dc031a67..724cc7c40e 100644 --- a/e2e/specs/mock/soft-default.spec.ts +++ b/e2e/specs/mock/soft-default.spec.ts @@ -1,6 +1,13 @@ import { expect, test } from '@playwright/test'; import type { Page } from '@playwright/test'; -import { NEW_CHAT_PATH, getAccessToken, mockReply, requestJson, sendMessage } from './helpers'; +import { + NEW_CHAT_PATH, + getAccessToken, + mockReply, + requestJson, + selectModelSpec, + sendMessage, +} from './helpers'; /** Label of the `softDefault: true` spec in e2e/config/librechat.e2e.yaml. */ const SOFT_DEFAULT_LABEL = 'E2E Soft Default'; @@ -127,9 +134,7 @@ test.describe('soft default model spec', () => { await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 }); }); - test('viewing an old soft conversation does not re-arm it over a prior selection', async ({ - page, - }) => { + test('viewing the soft conversation re-arms it on the next New Chat', async ({ page }) => { test.setTimeout(120000); await startFresh(page); await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 }); @@ -143,7 +148,40 @@ test.describe('soft default model spec', () => { await page.goto(softConvoUrl, { timeout: 10000 }); await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 }); + // Fresh load (not the in-memory SPA transition, which masks the regression): the + // cold ChatRoute path resolves the New Chat purely from getDefaultModelSpec. + await page.goto(NEW_CHAT_PATH, { timeout: 10000 }); + await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 }); + await expect(modelTrigger(page)).not.toHaveText('Select a model'); + }); + + // Regression: softDefault spec on an endpoint kept out of `addedEndpoints` (e.g. a + // bedrock spec with `addedEndpoints: [agents, ]`). Using the custom endpoint + // leaves a model in history under a key the spec preset never matches, which used to + // suppress the soft default and strand a freshly loaded New Chat on the unselectable + // endpoint ("Select a model"). The spec must re-arm when it was the conversation used + // last. A cold load is used because the SPA New Chat transition resolves non- + // deterministically and can mask the dropped spec. + test('re-arms on a fresh New Chat when the spec endpoint is outside the allow-list', async ({ + page, + }) => { + test.setTimeout(120000); + await startFresh(page); + await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 }); + + await selectEphemeralModel(page); + await sendAndAwaitReply(page, 'history on a different endpoint'); + await newChat(page); - await expect(modelTrigger(page)).not.toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 }); + await selectModelSpec(page, SOFT_DEFAULT_LABEL); + await sendAndAwaitReply(page, 'soft spec used last'); + const specConvoUrl = page.url(); + + await page.goto(specConvoUrl, { timeout: 10000 }); + await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 }); + + await page.goto(NEW_CHAT_PATH, { timeout: 10000 }); + await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 }); + await expect(modelTrigger(page)).not.toHaveText('Select a model'); }); });