diff --git a/client/src/utils/__tests__/getDefaultModelSpec.test.ts b/client/src/utils/__tests__/getDefaultModelSpec.test.ts index 7a8fe01818..268f222b61 100644 --- a/client/src/utils/__tests__/getDefaultModelSpec.test.ts +++ b/client/src/utils/__tests__/getDefaultModelSpec.test.ts @@ -386,7 +386,7 @@ describe('getDefaultModelSpec', () => { expect(result).toEqual({ softDefault: softSpec }); }); - it('applies the soft default despite a stored agent when addedEndpoints only includes agents', () => { + it('yields to a stored agent when addedEndpoints only includes agents', () => { persistAgentSelection('agent_abc'); const result = getDefaultModelSpec( @@ -397,15 +397,91 @@ describe('getDefaultModelSpec', () => { fullEndpointsConfig, ); + expect(result).toBeUndefined(); + }); + + it('yields to a stored agent when agents is the only endpoint', () => { + persistAgentSelection('agent_abc'); + + const result = getDefaultModelSpec( + createStartupConfig([otherSpec, softSpec], { prioritize: false }), + agentsOnlyEndpointsConfig, + ); + + expect(result).toBeUndefined(); + }); + + it('yields to a stored agent under a prioritized agents-only allow-list', () => { + persistAgentSelection('agent_abc'); + + const result = getDefaultModelSpec( + createStartupConfig([otherSpec, softSpec], { + addedEndpoints: [EModelEndpoint.agents], + }), + agentsOnlyEndpointsConfig, + ); + + expect(result).toBeUndefined(); + }); + + it('yields to a stored assistant when assistants is the only added endpoint', () => { + localStorage.setItem( + `${LocalStorageKeys.LAST_CONVO_SETUP}_0`, + JSON.stringify({ + endpoint: EModelEndpoint.assistants, + assistant_id: 'asst_abc', + model: null, + spec: null, + }), + ); + + const result = getDefaultModelSpec( + createStartupConfig([otherSpec, softSpec], { + prioritize: false, + addedEndpoints: [EModelEndpoint.assistants], + }), + { [EModelEndpoint.assistants]: { order: 0 } } as TEndpointsConfig, + ); + + expect(result).toBeUndefined(); + }); + + it('applies the soft default over a stored ephemeral agent id in an agents-only allow-list', () => { + persistAgentSelection(Constants.EPHEMERAL_AGENT_ID); + + const result = getDefaultModelSpec( + createStartupConfig([otherSpec, softSpec], { + prioritize: false, + addedEndpoints: [EModelEndpoint.agents], + }), + fullEndpointsConfig, + ); + expect(result).toEqual({ softDefault: softSpec }); }); - it('applies the soft default despite a stored agent when agents is the only endpoint', () => { + it('applies the soft default over endpoint → model residue in an agents-only allow-list', () => { + persistEphemeralSelection('bedrock', 'claude-sonnet-4-6'); + + const result = getDefaultModelSpec( + createStartupConfig([otherSpec, softSpec], { + addedEndpoints: [EModelEndpoint.agents], + }), + agentsOnlyEndpointsConfig, + ); + + expect(result).toEqual({ softDefault: softSpec }); + }); + + it('applies the soft default over a stored agent when the endpoints config lacks agents', () => { persistAgentSelection('agent_abc'); const result = getDefaultModelSpec( - createStartupConfig([otherSpec, softSpec], { prioritize: false }), - agentsOnlyEndpointsConfig, + createStartupConfig([otherSpec, softSpec], { + prioritize: false, + addedEndpoints: [EModelEndpoint.agents], + }), + { [EModelEndpoint.openAI]: { order: 0 } } as TEndpointsConfig, ); expect(result).toEqual({ softDefault: softSpec }); @@ -448,7 +524,7 @@ describe('getDefaultModelSpec', () => { }); it('detects an agents-only allow-list before the endpoints config loads', () => { - persistAgentSelection('agent_abc'); + persistEphemeralSelection('bedrock', 'claude-sonnet-4-6'); const result = getDefaultModelSpec( createStartupConfig([otherSpec, softSpec], { @@ -460,5 +536,19 @@ describe('getDefaultModelSpec', () => { expect(result).toEqual({ softDefault: softSpec }); }); + + it('yields to a stored agent in an agents-only allow-list before the endpoints config loads', () => { + persistAgentSelection('agent_abc'); + + const result = getDefaultModelSpec( + createStartupConfig([otherSpec, softSpec], { + prioritize: false, + addedEndpoints: [EModelEndpoint.agents], + }), + undefined, + ); + + expect(result).toBeUndefined(); + }); }); }); diff --git a/client/src/utils/endpoints.ts b/client/src/utils/endpoints.ts index 6d652d1560..5d79c78726 100644 --- a/client/src/utils/endpoints.ts +++ b/client/src/utils/endpoints.ts @@ -212,6 +212,47 @@ function hasEphemeralModelOptions({ ); } +/** + * Whether the stored setup names a concrete agent/assistant pick the selector + * still offers. Picker-only deployments (e.g. `addedEndpoints: [agents]`) have + * no ephemeral endpoint → model options, yet an agent selected there is a real + * choice the soft default must carry forward; ephemeral agent ids and picks + * whose endpoint left the allow-list or endpoints config remain residue. + */ +function hasSelectableEntitySelection({ + selection, + endpointsConfig, + addedEndpoints, + modelSelect, +}: { + selection?: Partial; + endpointsConfig?: t.TEndpointsConfig; + addedEndpoints?: Array; + modelSelect?: boolean; +}): boolean { + const endpoint = selection?.endpoint; + if (!modelSelect || !endpoint) { + return false; + } + const isAgentPick = + isAgentsEndpoint(endpoint) && + hasSelectionValue(selection.agent_id) && + !isEphemeralAgentId(selection.agent_id); + const isAssistantPick = + isAssistantsEndpoint(endpoint) && hasSelectionValue(selection.assistant_id); + if (!isAgentPick && !isAssistantPick) { + return false; + } + const included = new Set(addedEndpoints ?? []); + if (included.size > 0 && !included.has(endpoint)) { + return false; + } + if (endpointsConfig == null || Object.keys(endpointsConfig).length === 0) { + return true; + } + return endpointsConfig[endpoint] != null; +} + /** Get the conditional logic for switching conversations */ export function getConvoSwitchLogic(params: ConversationInitParams): InitiatedTemplateResult { const { conversation, newEndpoint, endpointsConfig, modularChat = false } = params; @@ -352,9 +393,12 @@ export function applyModelSpecEphemeralAgent({ * 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. + * also wins whenever the selector offers no ephemeral endpoint → model options — unless + * the setup names a concrete agent/assistant the selector still offers, the one real + * selection picker-only deployments provide — so lingering endpoint/model residue never + * strands a new chat on an unselectable endpoint. 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, @@ -393,12 +437,15 @@ export function getDefaultModelSpec( if (lastSpec?.name === softDefaultSpec.name) { return { softDefault: softDefaultSpec }; } + const modelSelect = interfaceConfig?.modelSelect; const yieldsToSelection = - hasModelSelection(lastSetup) && - hasEphemeralModelOptions({ + (hasModelSelection(lastSetup) && + hasEphemeralModelOptions({ endpointsConfig, addedEndpoints, modelSelect })) || + hasSelectableEntitySelection({ + selection: lastSetup, endpointsConfig, addedEndpoints, - modelSelect: interfaceConfig?.modelSelect, + modelSelect, }); return yieldsToSelection ? undefined : { softDefault: softDefaultSpec }; } diff --git a/e2e/specs/mock/soft-default.spec.ts b/e2e/specs/mock/soft-default.spec.ts index 724cc7c40e..98cf01062f 100644 --- a/e2e/specs/mock/soft-default.spec.ts +++ b/e2e/specs/mock/soft-default.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import type { TStartupConfig } from 'librechat-data-provider'; import type { Page } from '@playwright/test'; import { NEW_CHAT_PATH, @@ -155,6 +156,47 @@ test.describe('soft default model spec', () => { await expect(modelTrigger(page)).not.toHaveText('Select a model'); }); + // Regression: agents-only deployment (`addedEndpoints: [agents]`) — the selector + // offers specs and agent picks only, so `hasEphemeralModelOptions` is false and the + // soft default used to re-arm on every New Chat, discarding the user's agent. A + // concrete agent pick is the one real selection such deployments provide and must + // survive New Chat and a cold load; the soft default must still land fresh + // instances. The allow-list is narrowed via `/api/config` interception because the + // gate resolves entirely client-side from the startup config. + test('a selected agent survives New Chat in an agents-only allow-list', async ({ page }) => { + test.setTimeout(120000); + await page.route('**/api/config', async (route) => { + const response = await route.fetch(); + const config = (await response.json()) as TStartupConfig; + if (config.modelSpecs) { + config.modelSpecs = { ...config.modelSpecs, addedEndpoints: ['agents'] }; + } + await route.fulfill({ response, json: config }); + }); + + await startFresh(page); + await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 }); + + const agentName = uniqueName('E2E Agents Only'); + await createAgent(page, agentName); + await page.goto(NEW_CHAT_PATH, { timeout: 10000 }); + + await selectAgent(page, agentName); + await sendAndAwaitReply(page, 'agents-only agent conversation'); + + await newChat(page); + await expect(modelTrigger(page)).toContainText(agentName, { timeout: 15000 }); + + // Cold load (not the SPA transition): ChatRoute resolves purely from getDefaultModelSpec. + await page.goto(NEW_CHAT_PATH, { timeout: 10000 }); + await expect(modelTrigger(page)).toContainText(agentName, { timeout: 15000 }); + + // The soft default still owns the fresh-instance landing under this allow-list. + await page.evaluate(() => localStorage.clear()); + await page.goto(NEW_CHAT_PATH, { timeout: 10000 }); + await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 }); + }); + // 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