🛬 fix: Prevent Viewed Conversations from Re-Arming the Soft Default Spec (#13699)

This commit is contained in:
Danny Avila 2026-06-11 20:52:17 -04:00 committed by GitHub
parent a8a63604b9
commit 2d6b7df3ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 108 additions and 18 deletions

View file

@ -1,4 +1,4 @@
import { EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import { Constants, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import type { TModelSpec, TStartupConfig, TEndpointsConfig } from 'librechat-data-provider';
import { getDefaultModelSpec } from '../endpoints';
@ -51,12 +51,13 @@ const writeLastModel = (endpoint: string, model: string) => {
};
/** Mirrors what the conversation effect persists after a spec preset is applied */
const persistAppliedSpec = (spec: TModelSpec) => {
const persistAppliedSpec = (spec: TModelSpec, conversationId: string = Constants.NEW_CONVO) => {
localStorage.setItem(LocalStorageKeys.LAST_SPEC, spec.name);
writeLastModel(spec.preset.endpoint as string, spec.preset.model as string);
localStorage.setItem(
`${LocalStorageKeys.LAST_CONVO_SETUP}_0`,
JSON.stringify({
conversationId,
endpoint: spec.preset.endpoint,
model: spec.preset.model,
spec: spec.name,
@ -225,7 +226,7 @@ describe('getDefaultModelSpec', () => {
expect(result).toEqual({ last: otherSpec });
});
it('yields to a stored agent even when it matches the soft default agent spec', () => {
it('treats a matching stored agent as residue when the soft default points to an agent', () => {
const softAgentSpec = createModelSpec('soft-agent-spec', {
softDefault: true,
preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_soft' },
@ -237,10 +238,10 @@ describe('getDefaultModelSpec', () => {
fullEndpointsConfig,
);
expect(result).toBeUndefined();
expect(result).toEqual({ softDefault: softAgentSpec });
});
it('yields to a stored model selection matching the soft default preset', () => {
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 }),
@ -251,6 +252,41 @@ describe('getDefaultModelSpec', () => {
fullEndpointsConfig,
);
expect(result).toEqual({ softDefault: softSpec });
});
it('stays soft after the first conversation is sent', () => {
persistAppliedSpec(softSpec, 'a8b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d');
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
fullEndpointsConfig,
);
expect(result).toEqual({ softDefault: softSpec });
});
it('does not re-arm from viewing an old soft conversation after an ephemeral pick', () => {
persistEphemeralSelection(EModelEndpoint.anthropic, 'claude-sonnet-4-6');
persistAppliedSpec(softSpec, 'a8b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d');
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
fullEndpointsConfig,
);
expect(result).toBeUndefined();
});
it('does not re-arm from viewing an old soft conversation after an agent pick', () => {
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, 'agent_abc');
persistAppliedSpec(softSpec, 'a8b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d');
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
fullEndpointsConfig,
);
expect(result).toBeUndefined();
});

View file

@ -139,7 +139,7 @@ interface InitiatedTemplateResult {
type StoredModelSelection = Pick<
t.TConversation,
'endpoint' | 'model' | 'spec' | 'agent_id' | 'assistant_id'
'endpoint' | 'model' | 'spec' | 'agent_id' | 'assistant_id' | 'conversationId'
>;
function hasSelectionValue(value?: string | null): boolean {
@ -160,14 +160,15 @@ function parseStoredModelSelection(
}
}
function hasStoredPrefixValue(prefix: string): boolean {
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;
}
if (hasSelectionValue(localStorage.getItem(key))) {
const value = localStorage.getItem(key);
if (hasSelectionValue(value) && value !== ignoreValue) {
return true;
}
}
@ -175,7 +176,7 @@ function hasStoredPrefixValue(prefix: string): boolean {
return false;
}
function hasStoredModelValue(): boolean {
function hasStoredModelValue(softPreset?: t.TModelSpecPreset): boolean {
const storedModelValue = localStorage.getItem(LocalStorageKeys.LAST_MODEL);
if (!storedModelValue) {
return false;
@ -183,7 +184,11 @@ function hasStoredModelValue(): boolean {
try {
const storedModels = JSON.parse(storedModelValue) as Record<string, string | null | undefined>;
return Object.values(storedModels).some(hasSelectionValue);
return Object.entries(storedModels).some(
([endpoint, model]) =>
hasSelectionValue(model) &&
!(endpoint === softPreset?.endpoint && model === softPreset?.model),
);
} catch {
return false;
}
@ -205,22 +210,23 @@ export function hasModelSelection(selection?: Partial<StoredModelSelection> | nu
/**
* Whether localStorage holds a model selection the user actually made.
* Only spec entries naming `softDefaultSpec` are residue of the soft default
* itself selections that merely match its preset still count as user choices.
* 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()) {
if (hasStoredModelValue(softPreset)) {
return true;
}
if (
hasStoredPrefixValue(LocalStorageKeys.AGENT_ID_PREFIX) ||
hasStoredPrefixValue(LocalStorageKeys.ASST_ID_PREFIX)
hasStoredPrefixValue(LocalStorageKeys.AGENT_ID_PREFIX, softPreset?.agent_id) ||
hasStoredPrefixValue(LocalStorageKeys.ASST_ID_PREFIX, softPreset?.assistant_id)
) {
return true;
}
@ -406,7 +412,9 @@ 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, and acts
* 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.
*/
@ -434,7 +442,10 @@ export function getDefaultModelSpec(
if (!softDefaultSpec) {
return;
}
if (lastConversationSetup?.spec === softDefaultSpec.name) {
if (
lastConversationSetup?.spec === softDefaultSpec.name &&
lastConversationSetup?.conversationId === Constants.NEW_CONVO
) {
return { softDefault: softDefaultSpec };
}
const ephemeralOptions = hasEphemeralModelOptions({

View file

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { NEW_CHAT_PATH, getAccessToken, requestJson } from './helpers';
import { NEW_CHAT_PATH, getAccessToken, mockReply, requestJson, sendMessage } from './helpers';
/** Label of the `softDefault: true` spec in e2e/config/librechat.e2e.yaml. */
const SOFT_DEFAULT_LABEL = 'E2E Soft Default';
@ -53,6 +53,18 @@ async function selectEphemeralModel(page: Page) {
await expect(modelTrigger(page)).toContainText(EPHEMERAL_ENDPOINT.model);
}
async function sendAndAwaitReply(page: Page, text: string) {
const response = await sendMessage(page, text);
expect(response.ok()).toBeTruthy();
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 });
}
async function newChat(page: Page) {
await page.getByTestId('new-chat-button').click();
await expect(page).toHaveURL(/\/c\/new/, { timeout: 15000 });
}
test.describe('soft default model spec', () => {
test('applies the soft default on a fresh instance and stays applied across reloads', async ({
page,
@ -103,4 +115,35 @@ test.describe('soft default model spec', () => {
await expect(page).toHaveURL(/\/c\/new/, { timeout: 15000 });
await expect(modelTrigger(page)).toContainText(EPHEMERAL_ENDPOINT.model, { timeout: 15000 });
});
test('stays soft on New Chat after the first conversation is sent', async ({ page }) => {
test.setTimeout(120000);
await startFresh(page);
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
await sendAndAwaitReply(page, 'first soft conversation');
await newChat(page);
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.setTimeout(120000);
await startFresh(page);
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
await sendAndAwaitReply(page, 'soft history conversation');
const softConvoUrl = page.url();
await newChat(page);
await selectEphemeralModel(page);
await page.goto(softConvoUrl, { timeout: 10000 });
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
await newChat(page);
await expect(modelTrigger(page)).not.toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
});
});