mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🔌 fix: Preserve Ephemeral MCP Selections Across Model Switches (#13697)
The no-spec branch of `useApplyModelSpecEffects` (added in #11796) reset `ephemeralAgentByConvoId` to null on every `newConversation` call when model specs are configured. On in-place model/endpoint switches (modular chat, same conversation or new-chat draft), BadgeRowContext never refills from localStorage — its init effect only re-runs when the storage suffix or spec changes — so the MCP selection (and tool toggles) were silently dropped from subsequent request payloads while the MCP badge kept displaying them. Reset now only happens on context transitions (leaving a spec, or moving to a different conversation key), where a BadgeRowContext refill is guaranteed; in-place non-spec switches preserve the ephemeral agent. - Gate the no-spec reset on `prevSpecName` / `prevConvoId`, passed from `newConversation` via a snapshot read of the pre-switch conversation - Add jest coverage for all five branches of the no-spec path - Add e2e spec asserting `ephemeralAgent.mcp` stays in the chat payload after a new-chat model switch and after regenerate on a switched conversation (verified failing before the fix, passing after) - Add non-spec "Mock Provider D" endpoint to the e2e config so tests can switch between two real ephemeral endpoints; widen `MockEndpoint` type
This commit is contained in:
parent
731a7c57c1
commit
b39ec16ff0
6 changed files with 268 additions and 6 deletions
|
|
@ -50,6 +50,18 @@ endpoints:
|
|||
titleConvo: false
|
||||
modelDisplayLabel: 'Mock Provider C'
|
||||
|
||||
# Second non-spec endpoint so e2e tests can switch between two real
|
||||
# ephemeral endpoints (e.g. MCP selection persistence across switches).
|
||||
- name: 'Mock Provider D'
|
||||
apiKey: 'e2e-mock-key-d'
|
||||
baseURL: 'http://127.0.0.1:8889/v1'
|
||||
models:
|
||||
default:
|
||||
- 'mock-model-d'
|
||||
fetch: false
|
||||
titleConvo: false
|
||||
modelDisplayLabel: 'Mock Provider D'
|
||||
|
||||
modelSpecs:
|
||||
prioritize: true
|
||||
# Enforcement would reject sends from the non-spec paths addedEndpoints
|
||||
|
|
@ -59,6 +71,7 @@ modelSpecs:
|
|||
# set) limited to entries that don't collide with the spec labels above.
|
||||
addedEndpoints:
|
||||
- 'Mock Provider C'
|
||||
- 'Mock Provider D'
|
||||
- 'agents'
|
||||
list:
|
||||
- name: 'e2e-mock-provider-a'
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export const MOCK_ENDPOINTS = [
|
|||
{ label: 'Mock Provider B', model: 'mock-model-b' },
|
||||
] as const;
|
||||
|
||||
export type MockEndpoint = (typeof MOCK_ENDPOINTS)[number];
|
||||
export type MockEndpoint = { label: string; model: string };
|
||||
|
||||
export const NEW_CHAT_PATH = '/c/new';
|
||||
|
||||
|
|
|
|||
84
e2e/specs/mock/mcp-ephemeral.spec.ts
Normal file
84
e2e/specs/mock/mcp-ephemeral.spec.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Page, Response } from '@playwright/test';
|
||||
import {
|
||||
NEW_CHAT_PATH,
|
||||
isAgentsStream,
|
||||
mockReply,
|
||||
selectMockEndpoint,
|
||||
sendMessage,
|
||||
} from './helpers';
|
||||
|
||||
/** Non-spec endpoints from e2e/config/librechat.e2e.yaml — switching between
|
||||
* them exercises the no-spec `applyModelSpecEffects` path that previously
|
||||
* wiped the ephemeral agent (MCP selection) for the active conversation. */
|
||||
const PROVIDER_C = { label: 'Mock Provider C', model: 'mock-model-c' };
|
||||
const PROVIDER_D = { label: 'Mock Provider D', model: 'mock-model-d' };
|
||||
|
||||
const MCP_SERVER_NAME = 'e2e-memory';
|
||||
const MCP_SERVER_TITLE = 'E2E Memory';
|
||||
|
||||
const uniqueText = (prefix: string) => `${prefix} ${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
|
||||
|
||||
const mcpBadge = (page: Page) => page.getByRole('button', { name: new RegExp(MCP_SERVER_TITLE) });
|
||||
|
||||
/** Select the MCP server from the composer's ephemeral MCP dropdown. */
|
||||
async function selectEphemeralMCP(page: Page) {
|
||||
await page.getByRole('button', { name: 'MCP Servers', exact: true }).click();
|
||||
const serverItem = page.getByRole('menuitemcheckbox', { name: new RegExp(MCP_SERVER_TITLE) });
|
||||
await expect(serverItem).toBeVisible();
|
||||
await serverItem.click();
|
||||
await expect(serverItem).toHaveAttribute('aria-checked', 'true');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(mcpBadge(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/** The `ephemeralAgent.mcp` array sent with a chat request. */
|
||||
function requestMCP(response: Response): string[] | undefined {
|
||||
const body = response.request().postDataJSON() as { ephemeralAgent?: { mcp?: string[] } };
|
||||
return body.ephemeralAgent?.mcp;
|
||||
}
|
||||
|
||||
test.describe('ephemeral MCP selection persistence', () => {
|
||||
test('keeps the MCP selection when switching models on a new chat', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
||||
|
||||
await selectMockEndpoint(page, PROVIDER_C);
|
||||
await selectEphemeralMCP(page);
|
||||
|
||||
await selectMockEndpoint(page, PROVIDER_D);
|
||||
await expect(mcpBadge(page)).toBeVisible();
|
||||
|
||||
const response = await sendMessage(page, uniqueText('mcp new chat switch'));
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(requestMCP(response)).toContain(MCP_SERVER_NAME);
|
||||
|
||||
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
|
||||
});
|
||||
|
||||
test('keeps the MCP selection when regenerating after a model switch', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
||||
|
||||
await selectMockEndpoint(page, PROVIDER_C);
|
||||
await selectEphemeralMCP(page);
|
||||
|
||||
const first = await sendMessage(page, uniqueText('mcp regenerate switch'));
|
||||
expect(first.ok()).toBeTruthy();
|
||||
expect(requestMCP(first)).toContain(MCP_SERVER_NAME);
|
||||
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
|
||||
await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 });
|
||||
|
||||
await selectMockEndpoint(page, PROVIDER_D);
|
||||
await expect(mcpBadge(page)).toBeVisible();
|
||||
|
||||
const [regenerated] = await Promise.all([
|
||||
page.waitForResponse(isAgentsStream, { timeout: 30000 }),
|
||||
page.getByRole('button', { name: 'Regenerate', exact: true }).click(),
|
||||
]);
|
||||
expect(regenerated.ok()).toBeTruthy();
|
||||
expect(requestMCP(regenerated)).toContain(MCP_SERVER_NAME);
|
||||
|
||||
await expect(mockReply(page).first()).toBeVisible({ timeout: 20000 });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue