🪶 fix: Yield Soft Default Model Spec to Agent Picks in Picker-Only Deployments (#14515)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions

* 🪶 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<string, unknown> }`, 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.
This commit is contained in:
Danny Avila 2026-07-29 15:45:23 -04:00 committed by GitHub
parent cc813f430e
commit 91adcf3f2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 190 additions and 11 deletions

View file

@ -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();
});
});
});

View file

@ -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<StoredModelSelection>;
endpointsConfig?: t.TEndpointsConfig;
addedEndpoints?: Array<EModelEndpoint | string>;
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 };
}

View file

@ -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, <custom>]`). Using the custom endpoint
// leaves a model in history under a key the spec preset never matches, which used to