diff --git a/client/src/routes/ChatRoute.tsx b/client/src/routes/ChatRoute.tsx index 5f55df51dd..a514a60bfe 100644 --- a/client/src/routes/ChatRoute.tsx +++ b/client/src/routes/ChatRoute.tsx @@ -166,10 +166,6 @@ export default function ChatRoute() { } const getNewConvoPreset = () => { - const result = getDefaultModelSpec(startupConfig, endpointsQuery.data); - const spec = result?.default ?? result?.last ?? result?.softDefault; - const specPreset = spec ? getModelSpecPreset(spec) : undefined; - const queryParams: Record = {}; searchParams.forEach((value, key) => { if (key !== 'prompt' && key !== 'q' && key !== 'submit' && key !== 'projectId') { @@ -178,6 +174,18 @@ export default function ChatRoute() { }); const querySettings = processValidSettings(queryParams); + /** A spec named in the URL is an explicit selection: it must resolve to its own + * full preset, or stale last-selection state (endpoint/agent) fills the gaps. + * Names absent from the client config (e.g. `showInMenu: false`) stay in the + * query settings untouched, since they remain resolvable server-side by name. */ + const urlSpec = querySettings.spec + ? startupConfig?.modelSpecs?.list?.find((spec) => spec.name === querySettings.spec) + : undefined; + + const result = urlSpec ? undefined : getDefaultModelSpec(startupConfig, endpointsQuery.data); + const spec = urlSpec ?? result?.default ?? result?.last ?? result?.softDefault; + const specPreset = spec ? getModelSpecPreset(spec) : undefined; + if (Object.keys(querySettings).length > 0) { return mergeQuerySettingsWithSpec(specPreset, querySettings); } diff --git a/e2e/specs/mock/soft-default.spec.ts b/e2e/specs/mock/soft-default.spec.ts index 98cf01062f..640eed2bd1 100644 --- a/e2e/specs/mock/soft-default.spec.ts +++ b/e2e/specs/mock/soft-default.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import type { TStartupConfig } from 'librechat-data-provider'; import type { Page } from '@playwright/test'; +import { getPrimaryE2EUser } from '../../setup/users.mock'; import { NEW_CHAT_PATH, getAccessToken, @@ -13,6 +14,9 @@ import { /** Label of the `softDefault: true` spec in e2e/config/librechat.e2e.yaml. */ const SOFT_DEFAULT_LABEL = 'E2E Soft Default'; +/** Name (URL identity) of the `softDefault: true` spec in e2e/config/librechat.e2e.yaml. */ +const SOFT_DEFAULT_NAME = 'e2e-soft-default'; + /** Ephemeral endpoint from e2e/config/librechat.e2e.yaml with no mirroring spec. */ const EPHEMERAL_ENDPOINT = { label: 'Mock Provider C', model: 'mock-model-c' }; @@ -32,7 +36,7 @@ type AgentResponse = { name?: string | null; }; -async function createAgent(page: Page, name: string): Promise { +async function createAgent(page: Page, name: string, description?: string): Promise { const token = await getAccessToken(page); return requestJson(page, { path: '/api/agents', @@ -40,6 +44,7 @@ async function createAgent(page: Page, name: string): Promise { method: 'POST', body: { name, + ...(description ? { description } : {}), provider: 'Mock Provider A', model: 'mock-model-a', model_parameters: {}, @@ -226,4 +231,45 @@ test.describe('soft default model spec', () => { await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 }); await expect(modelTrigger(page)).not.toHaveText('Select a model'); }); + + // Regression: two tabs share localStorage — one on the soft default spec, one on a + // configured agent. Refreshing the agent tab stamps the agent as the last setup; a + // cold load of `/c/new?spec=` then carried only the spec NAME while the + // endpoint and agent_id resurfaced from storage, rendering a chimera (spec chip over + // an agent landing/composer). A spec named in the URL must resolve to its full preset. + test('a spec named in the URL wins over a previously selected agent on a cold load', async ({ + page, + }) => { + test.setTimeout(120000); + await startFresh(page); + await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 }); + + const agentName = uniqueName('E2E URL Spec Agent'); + const agentDescription = 'Powered by E2E Mock'; + await createAgent(page, agentName, agentDescription); + await page.goto(NEW_CHAT_PATH, { timeout: 10000 }); + await selectAgent(page, agentName); + + // Mirrors refreshing the agent tab: the restored selection is re-stamped as the + // last conversation setup. + await page.reload({ timeout: 10000 }); + await expect(modelTrigger(page)).toContainText(agentName, { timeout: 15000 }); + + await page.goto(`${NEW_CHAT_PATH}?spec=${SOFT_DEFAULT_NAME}`, { timeout: 10000 }); + await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 }); + + const main = page.getByRole('main'); + const composer = page.getByRole('textbox', { name: 'Message input' }); + const user = getPrimaryE2EUser(); + await expect(composer).toHaveAttribute('placeholder', /Mock Provider A/, { timeout: 15000 }); + await expect(main).toContainText(user.name, { timeout: 15000 }); + await expect(main).not.toContainText(agentName); + await expect(main).not.toContainText(agentDescription); + + // The mixed state used to be written back to localStorage; a follow-up cold load + // of a plain New Chat must stay on the spec, not resurrect the agent. + await page.goto(NEW_CHAT_PATH, { timeout: 10000 }); + await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 }); + await expect(main).not.toContainText(agentName); + }); });