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
|
|
@ -0,0 +1,147 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot, useRecoilValue } from 'recoil';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { Constants, EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TEphemeralAgent, TStartupConfig, TModelSpec } from 'librechat-data-provider';
|
||||
import { ephemeralAgentByConvoId, useUpdateEphemeralAgent } from '~/store/agents';
|
||||
import { useApplyModelSpecEffects } from '../useApplyModelSpecAgents';
|
||||
|
||||
const NEW_CONVO = Constants.NEW_CONVO as string;
|
||||
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<RecoilRoot>{children}</RecoilRoot>
|
||||
);
|
||||
|
||||
const createModelSpec = (name: string): TModelSpec =>
|
||||
({
|
||||
name,
|
||||
label: name,
|
||||
preset: {
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
model: name,
|
||||
},
|
||||
}) as TModelSpec;
|
||||
|
||||
const createStartupConfig = (list: TModelSpec[]): TStartupConfig =>
|
||||
({
|
||||
modelSpecs: {
|
||||
list,
|
||||
prioritize: false,
|
||||
},
|
||||
}) as TStartupConfig;
|
||||
|
||||
const specsConfig = () => createStartupConfig([createModelSpec('test-spec')]);
|
||||
|
||||
const useHarness = (conversationId: string) => {
|
||||
const applyModelSpecEffects = useApplyModelSpecEffects();
|
||||
const updateEphemeralAgent = useUpdateEphemeralAgent();
|
||||
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(conversationId));
|
||||
return { applyModelSpecEffects, updateEphemeralAgent, ephemeralAgent };
|
||||
};
|
||||
|
||||
describe('useApplyModelSpecEffects', () => {
|
||||
it('preserves an existing conversation ephemeral agent on an in-place model switch', () => {
|
||||
const conversationId = 'convo-123';
|
||||
const agent: TEphemeralAgent = { mcp: ['clickhouse'] };
|
||||
const { result } = renderHook(() => useHarness(conversationId), { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.updateEphemeralAgent(conversationId, agent);
|
||||
});
|
||||
expect(result.current.ephemeralAgent).toEqual(agent);
|
||||
|
||||
act(() => {
|
||||
result.current.applyModelSpecEffects({
|
||||
convoId: conversationId,
|
||||
specName: null,
|
||||
prevConvoId: conversationId,
|
||||
prevSpecName: null,
|
||||
startupConfig: specsConfig(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.ephemeralAgent).toEqual(agent);
|
||||
});
|
||||
|
||||
it('preserves a new conversation ephemeral agent on an in-place model switch', () => {
|
||||
const agent: TEphemeralAgent = { mcp: ['clickhouse'] };
|
||||
const { result } = renderHook(() => useHarness(NEW_CONVO), { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.updateEphemeralAgent(NEW_CONVO, agent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.applyModelSpecEffects({
|
||||
convoId: NEW_CONVO,
|
||||
specName: null,
|
||||
prevConvoId: NEW_CONVO,
|
||||
prevSpecName: null,
|
||||
startupConfig: specsConfig(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.ephemeralAgent).toEqual(agent);
|
||||
});
|
||||
|
||||
it('resets the ephemeral agent when switching away from a spec', () => {
|
||||
const { result } = renderHook(() => useHarness(NEW_CONVO), { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.updateEphemeralAgent(NEW_CONVO, { mcp: ['clickhouse'] });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.applyModelSpecEffects({
|
||||
convoId: NEW_CONVO,
|
||||
specName: null,
|
||||
prevConvoId: NEW_CONVO,
|
||||
prevSpecName: 'test-spec',
|
||||
startupConfig: specsConfig(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.ephemeralAgent).toBeNull();
|
||||
});
|
||||
|
||||
it('resets the new conversation ephemeral agent when leaving an existing conversation', () => {
|
||||
const { result } = renderHook(() => useHarness(NEW_CONVO), { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.updateEphemeralAgent(NEW_CONVO, { mcp: ['clickhouse'] });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.applyModelSpecEffects({
|
||||
convoId: NEW_CONVO,
|
||||
specName: null,
|
||||
prevConvoId: 'convo-123',
|
||||
prevSpecName: null,
|
||||
startupConfig: specsConfig(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.ephemeralAgent).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the ephemeral agent untouched when no specs are configured', () => {
|
||||
const agent: TEphemeralAgent = { mcp: ['clickhouse'] };
|
||||
const { result } = renderHook(() => useHarness(NEW_CONVO), { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.updateEphemeralAgent(NEW_CONVO, agent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.applyModelSpecEffects({
|
||||
convoId: NEW_CONVO,
|
||||
specName: null,
|
||||
prevConvoId: 'convo-123',
|
||||
prevSpecName: 'test-spec',
|
||||
startupConfig: {} as TStartupConfig,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.ephemeralAgent).toEqual(agent);
|
||||
});
|
||||
});
|
||||
|
|
@ -10,7 +10,11 @@ import { getModelSpec, applyModelSpecEphemeralAgent } from '~/utils';
|
|||
*
|
||||
* When a spec is provided, its tool settings are applied to the ephemeral agent.
|
||||
* When no spec is provided but specs are configured, the ephemeral agent is reset
|
||||
* to null so BadgeRowContext can apply localStorage defaults (non-spec experience).
|
||||
* to null on context transitions (leaving a spec, or moving to a different
|
||||
* conversation key) so BadgeRowContext refills values from localStorage — both
|
||||
* transitions re-trigger its init effect. In-place switches (same conversation,
|
||||
* non-spec → non-spec) keep the ephemeral agent state (e.g. MCP selections),
|
||||
* since no refill would follow the reset.
|
||||
*/
|
||||
export function useApplyModelSpecEffects() {
|
||||
const updateEphemeralAgent = useUpdateEphemeralAgent();
|
||||
|
|
@ -18,17 +22,25 @@ export function useApplyModelSpecEffects() {
|
|||
({
|
||||
convoId,
|
||||
specName,
|
||||
prevConvoId,
|
||||
prevSpecName,
|
||||
startupConfig,
|
||||
}: {
|
||||
convoId: string | null;
|
||||
specName?: string | null;
|
||||
prevConvoId?: string | null;
|
||||
prevSpecName?: string | null;
|
||||
startupConfig?: TStartupConfig;
|
||||
}) => {
|
||||
if (specName == null || !specName) {
|
||||
if (startupConfig?.modelSpecs?.list?.length) {
|
||||
/** Specs are configured but none selected — reset ephemeral agent to null
|
||||
* so BadgeRowContext fills all values (tool toggles + MCP) from localStorage. */
|
||||
updateEphemeralAgent((convoId ?? Constants.NEW_CONVO) || Constants.NEW_CONVO, null);
|
||||
if (!startupConfig?.modelSpecs?.list?.length) {
|
||||
return;
|
||||
}
|
||||
const targetId = (convoId ?? Constants.NEW_CONVO) || Constants.NEW_CONVO;
|
||||
const sourceId = (prevConvoId ?? Constants.NEW_CONVO) || Constants.NEW_CONVO;
|
||||
const isContextSwitch = Boolean(prevSpecName) || targetId !== sourceId;
|
||||
if (isContextSwitch) {
|
||||
updateEphemeralAgent(targetId, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
logger,
|
||||
} from '~/utils';
|
||||
import { useDeleteFilesMutation, useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import useGetConversation from './Conversations/useGetConversation';
|
||||
import useAssistantListMap from './Assistants/useAssistantListMap';
|
||||
import { useResetChatBadges } from './useChatBadges';
|
||||
import { useApplyModelSpecEffects } from './Agents';
|
||||
|
|
@ -46,6 +47,7 @@ const useNewConvo = (index = 0) => {
|
|||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const getConversation = useGetConversation(index);
|
||||
const applyModelSpecEffects = useApplyModelSpecEffects();
|
||||
const clearAllConversations = store.useClearConvoState();
|
||||
const defaultPreset = useRecoilValue(store.defaultPreset);
|
||||
|
|
@ -334,10 +336,13 @@ const useNewConvo = (index = 0) => {
|
|||
preset = getModelSpecPreset(defaultModelSpec);
|
||||
}
|
||||
|
||||
const prevConversation = getConversation();
|
||||
applyModelSpecEffects({
|
||||
startupConfig,
|
||||
specName: preset?.spec,
|
||||
convoId: conversation.conversationId,
|
||||
prevConvoId: prevConversation?.conversationId,
|
||||
prevSpecName: prevConversation?.spec,
|
||||
});
|
||||
|
||||
if (conversation.conversationId === Constants.NEW_CONVO && !modelsData) {
|
||||
|
|
@ -384,6 +389,7 @@ const useNewConvo = (index = 0) => {
|
|||
startupConfig,
|
||||
saveBadgesState,
|
||||
endpointsConfig,
|
||||
getConversation,
|
||||
pauseGlobalAudio,
|
||||
switchToConversation,
|
||||
applyModelSpecEffects,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue