From 91adcf3f2c3a16266d29e4cee7916518e807f9b3 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 29 Jul 2026 15:45:23 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=B6=20fix:=20Yield=20Soft=20Default=20?= =?UTF-8?q?Model=20Spec=20to=20Agent=20Picks=20in=20Picker-Only=20Deployme?= =?UTF-8?q?nts=20(#14515)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ๐Ÿชถ fix: Yield Soft Default Model Spec to Agent Picks in Picker-Only Deployments The `hasEphemeralModelOptions` gate makes the soft default canonical whenever the selector offers no ephemeral endpoint โ†’ model options, so lingering endpoint/model residue never strands a new chat on an unselectable endpoint. That gate swept in agent and assistant selections too: under an agents-only allow-list (`addedEndpoints: [agents]`), every New Chat re-armed the soft spec and discarded the agent the user had just selected, with no way to make the choice stick. An agent pick is the one real selection a picker-only deployment offers, so it now yields like any other selection, while endpoint/model residue keeps falling to the soft default. - Add `hasSelectableEntitySelection`: the stored setup yields when it names a non-ephemeral agent_id (or an assistant_id) on an endpoint the allow-list and endpoints config still expose. Ephemeral ids, and picks whose endpoint has since left the allow-list, stay residue so a stale entity cannot strand a new chat. - Invert the three unit cases that asserted the soft default outranking a stored agent under an agents-only allow-list; add coverage for assistants, prioritized configs, ephemeral agent ids, endpoint/model residue, an endpoints config without agents, and the pre-load allow-list path (35 cases, was 29). - Add an e2e regression test: under an intercepted agents-only allow-list, a selected agent survives New Chat and a cold load, while a cleared instance still lands on the soft default. * ๐Ÿงน chore: Type the Intercepted Startup Config in the Soft Default E2E The agents-only allow-list interception cast the `/api/config` response to `{ modelSpecs?: Record }`, discarding the startup-config schema at the exact point the test rewrites an API response โ€” so a future config shape change would go unchecked here. Reuse `TStartupConfig` instead, and only rewrite `modelSpecs` when the response actually carries it rather than fabricating it. --- .../__tests__/getDefaultModelSpec.test.ts | 100 +++++++++++++++++- client/src/utils/endpoints.ts | 59 +++++++++-- e2e/specs/mock/soft-default.spec.ts | 42 ++++++++ 3 files changed, 190 insertions(+), 11 deletions(-) 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