🥚 refactor: Default Agents to Preferred Stateful Workspace Scope (#14908)

* feat: add user default for stateful agent workspaces

* style: sort stateful workspace imports
This commit is contained in:
Danny Avila 2026-08-16 22:31:01 -04:00 committed by GitHub
parent f829aca9fb
commit 485abef3fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 570 additions and 18 deletions

View file

@ -1,4 +1,5 @@
const express = require('express');
const { createUserPreferencesHandler } = require('@librechat/api');
const {
updateUserPluginsController,
resendVerificationController,
@ -17,11 +18,17 @@ const {
} = require('~/server/middleware');
const settings = require('./settings');
const { updateUserStatefulCodeEnvironment } = require('~/models');
const router = express.Router();
const updateUserPreferences = createUserPreferencesHandler({
updateStatefulCodeEnvironment: updateUserStatefulCodeEnvironment,
});
router.use('/settings', settings);
router.get('/', requireJwtAuth, getUserController);
router.patch('/preferences', requireJwtAuth, updateUserPreferences);
router.get('/terms', requireJwtAuth, getTermsStatusController);
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
router.post('/plugins', requireJwtAuth, updateUserPluginsController);

View file

@ -0,0 +1,75 @@
import { useEffect, useState } from 'react';
import {
Select,
SelectItem,
SelectValue,
SelectContent,
SelectTrigger,
useToastContext,
} from '@librechat/client';
import type { StatefulCodeEnvironment } from 'librechat-data-provider';
import { useGetUserQuery, useUpdateUserPreferencesMutation } from '~/data-provider';
import { useLocalize } from '~/hooks';
const FALLBACK_ENVIRONMENT: StatefulCodeEnvironment = 'user';
export default function StatefulWorkspaceDefault() {
const localize = useLocalize();
const { showToast } = useToastContext();
const { data: user } = useGetUserQuery();
const savedEnvironment = user?.personalization?.statefulCodeEnvironment ?? FALLBACK_ENVIRONMENT;
const [environment, setEnvironment] = useState<StatefulCodeEnvironment>(savedEnvironment);
useEffect(() => {
setEnvironment(savedEnvironment);
}, [savedEnvironment]);
const mutation = useUpdateUserPreferencesMutation({
onSuccess: () =>
showToast({ message: localize('com_ui_preferences_updated'), status: 'success' }),
onError: () => {
setEnvironment(savedEnvironment);
showToast({ message: localize('com_ui_error_updating_preferences'), status: 'error' });
},
});
const handleChange = (value: string) => {
const statefulCodeEnvironment = value as StatefulCodeEnvironment;
setEnvironment(statefulCodeEnvironment);
mutation.mutate({ statefulCodeEnvironment });
};
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<label id="default-stateful-workspace-label" htmlFor="default-stateful-workspace">
{localize('com_ui_default_stateful_workspace')}
</label>
<p id="default-stateful-workspace-description" className="mt-1 text-xs text-text-secondary">
{localize('com_ui_default_stateful_workspace_description')}
</p>
</div>
<Select value={environment} onValueChange={handleChange} disabled={mutation.isLoading}>
<SelectTrigger
id="default-stateful-workspace"
className="w-full shrink-0 sm:w-[220px]"
aria-labelledby="default-stateful-workspace-label"
aria-describedby="default-stateful-workspace-description"
data-testid="default-stateful-workspace"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">{localize('com_ui_stateful_code_environment_user')}</SelectItem>
<SelectItem value="agent-user">
{localize('com_ui_stateful_code_environment_agent_user')}
</SelectItem>
<SelectItem value="conversation">
{localize('com_ui_stateful_code_environment_conversation')}
</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View file

@ -9,6 +9,7 @@ const ctx: SettingsContextValue = {
balanceEnabled: false,
hasAnyPersonalizationFeature: false,
hasMemoryOptOut: false,
hasStatefulCodeSessions: false,
hasRemoteAgents: false,
hasUserProvidedEndpoints: false,
hasMultiConvo: false,

View file

@ -0,0 +1,74 @@
import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import StatefulWorkspaceDefault from '../StatefulWorkspaceDefault';
const mockMutate = jest.fn();
const mockShowToast = jest.fn();
jest.mock('@librechat/client', () => ({
Select: ({
value,
onValueChange,
disabled,
children,
}: {
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
children: ReactNode;
}) => (
<select
aria-label="workspace-default"
value={value}
disabled={disabled}
onChange={(event) => onValueChange(event.target.value)}
>
{children}
</select>
),
SelectTrigger: ({ children }: { children: ReactNode }) => children,
SelectValue: () => null,
SelectContent: ({ children }: { children: ReactNode }) => children,
SelectItem: ({ value, children }: { value: string; children: ReactNode }) => (
<option value={value}>{children}</option>
),
useToastContext: () => ({ showToast: mockShowToast }),
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('~/data-provider', () => ({
useGetUserQuery: () => ({
data: { personalization: { statefulCodeEnvironment: 'agent-user' } },
}),
useUpdateUserPreferencesMutation: () => ({
mutate: mockMutate,
isLoading: false,
}),
}));
describe('StatefulWorkspaceDefault', () => {
beforeEach(() => {
mockMutate.mockClear();
});
it('shows the saved user preference', () => {
render(<StatefulWorkspaceDefault />);
expect(screen.getByRole('combobox', { name: 'workspace-default' })).toHaveValue('agent-user');
});
it('persists a new default for future agents', async () => {
render(<StatefulWorkspaceDefault />);
await userEvent.selectOptions(
screen.getByRole('combobox', { name: 'workspace-default' }),
'conversation',
);
expect(mockMutate).toHaveBeenCalledWith({ statefulCodeEnvironment: 'conversation' });
});
});

View file

@ -11,6 +11,7 @@ const settingsContext: SettingsContextValue = {
balanceEnabled: false,
hasAnyPersonalizationFeature: false,
hasMemoryOptOut: false,
hasStatefulCodeSessions: false,
hasRemoteAgents: false,
hasUserProvidedEndpoints: false,
hasMultiConvo: false,
@ -87,4 +88,16 @@ describe('settings registry', () => {
).toBe(true);
});
});
describe('stateful workspace default visibility', () => {
const entry = registry.find((setting) => setting.id === 'defaultStatefulWorkspace');
it('shows the setting when stateful code sessions are available', () => {
expect(entry?.show?.({ ...settingsContext, hasStatefulCodeSessions: true })).toBe(true);
});
it('hides the setting when stateful code sessions are unavailable', () => {
expect(entry?.show?.({ ...settingsContext, hasStatefulCodeSessions: false })).toBe(false);
});
});
});

View file

@ -1,16 +1,17 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { AgentCapabilities, PermissionTypes, Permissions } from 'librechat-data-provider';
import type { SettingsContextValue } from './types';
import useProviderKeys from '../SettingsTabs/ProviderKeys/useProviderKeys';
import { useHasAccess, useAuthContext, useGetAgentsConfig } from '~/hooks';
import usePersonalizationAccess from '~/hooks/usePersonalizationAccess';
import { useHasAccess, useAuthContext } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import store from '~/store';
export function useSettingsContext(): SettingsContextValue {
const { user } = useAuthContext();
const { data: startupConfig } = useGetStartupConfig();
const { agentsConfig } = useGetAgentsConfig();
const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess();
const hasRemoteAgents = useHasAccess({
@ -38,12 +39,15 @@ export function useSettingsContext(): SettingsContextValue {
const hasPromptsBool = hasPrompts === true;
const engineTTS = useRecoilValue<string>(store.engineTTS);
const hasUserProvidedEndpoints = useProviderKeys().length > 0;
const hasStatefulCodeSessions =
agentsConfig?.capabilities.includes(AgentCapabilities.stateful_code_sessions) ?? false;
return useMemo(
() => ({
balanceEnabled,
hasAnyPersonalizationFeature,
hasMemoryOptOut,
hasStatefulCodeSessions,
hasRemoteAgents: hasRemoteAgentsBool,
hasUserProvidedEndpoints,
hasMultiConvo: hasMultiConvoBool,
@ -60,6 +64,7 @@ export function useSettingsContext(): SettingsContextValue {
balanceEnabled,
hasAnyPersonalizationFeature,
hasMemoryOptOut,
hasStatefulCodeSessions,
hasRemoteAgentsBool,
hasUserProvidedEndpoints,
hasMultiConvoBool,

View file

@ -29,6 +29,7 @@ import ChatTitleInTab from '../SettingsTabs/General/ChatTitleInTab';
import AdvancedPrompts from '../SettingsTabs/Chat/AdvancedPrompts';
import DuringRunAction from '../SettingsTabs/Chat/DuringRunAction';
import DeleteAccount from '../SettingsTabs/Account/DeleteAccount';
import StatefulWorkspaceDefault from './StatefulWorkspaceDefault';
import { ForkSettings } from '../SettingsTabs/Chat/ForkSettings';
import ChatDirection from '../SettingsTabs/Chat/ChatDirection';
import { DeleteCache } from '../SettingsTabs/Data/DeleteCache';
@ -532,6 +533,16 @@ export const registry: SettingEntry[] = [
show: (ctx) => ctx.hasMemoryOptOut,
Component: MemoryToggle,
},
// Data controls · Code execution
{
id: 'defaultStatefulWorkspace',
tab: DATA,
section: 'codeExecution',
labelKey: 'com_ui_default_stateful_workspace',
keywords: ['agent', 'code', 'environment', 'sandbox', 'stateful', 'workspace'],
show: (ctx) => ctx.hasStatefulCodeSessions,
Component: StatefulWorkspaceDefault,
},
// Data controls · Your data
{
id: 'importConversations',

View file

@ -27,6 +27,7 @@ export type SectionId =
| 'stt'
| 'tts'
| 'memory'
| 'codeExecution'
| 'data'
| 'apiKeys'
| 'langfuse'
@ -40,6 +41,7 @@ export interface SettingsContextValue {
balanceEnabled: boolean;
hasAnyPersonalizationFeature: boolean;
hasMemoryOptOut: boolean;
hasStatefulCodeSessions: boolean;
hasRemoteAgents: boolean;
hasUserProvidedEndpoints: boolean;
hasMultiConvo: boolean;
@ -141,6 +143,7 @@ export const TABS: TabMeta[] = [
icon: createElement(DataIcon),
sections: [
{ id: 'memory', labelKey: 'com_ui_settings_section_memory' },
{ id: 'codeExecution', labelKey: 'com_ui_settings_section_code_execution' },
{ id: 'data', labelKey: 'com_ui_settings_section_data' },
{ id: 'apiKeys', labelKey: 'com_ui_settings_section_api_keys' },
{ id: 'danger', labelKey: 'com_ui_settings_section_danger_zone', danger: true },

View file

@ -15,11 +15,12 @@ import {
} from '@librechat/client';
import type { StatefulCodeEnvironment } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
import { useLocalize } from '~/hooks';
import { useAuthContext, useLocalize } from '~/hooks';
import { ESide } from '~/common';
export default function StatefulSessions() {
const localize = useLocalize();
const { user } = useAuthContext();
const methods = useFormContext<AgentForm>();
const { setValue, watch } = methods;
@ -30,7 +31,11 @@ export default function StatefulSessions() {
const handleChange = (value: boolean) => {
setValue(AgentCapabilities.stateful_code_sessions, value, { shouldDirty: true });
if (value && !watch('stateful_code_environment')) {
setValue('stateful_code_environment', 'user', { shouldDirty: true });
setValue(
'stateful_code_environment',
user?.personalization?.statefulCodeEnvironment ?? 'user',
{ shouldDirty: true },
);
}
};

View file

@ -286,6 +286,7 @@ export default function AgentPanel() {
setCurrentAgentId,
agent_id: current_agent_id,
} = useAgentPanelContext();
const defaultStatefulCodeEnvironment = user?.personalization?.statefulCodeEnvironment ?? 'user';
const { onSelect: onSelectAgent } = useSelectAgent();
@ -307,7 +308,7 @@ export default function AgentPanel() {
const models = useMemo(() => modelsQuery.data ?? {}, [modelsQuery.data]);
const methods = useForm<AgentForm>({
defaultValues: getDefaultAgentFormValues(),
defaultValues: getDefaultAgentFormValues(defaultStatefulCodeEnvironment),
mode: 'onChange',
});
@ -579,6 +580,7 @@ export default function AgentPanel() {
agentQuery={agentQuery}
setCurrentAgentId={setCurrentAgentId}
selectedAgentId={agentQuery.isInitialLoading ? null : (current_agent_id ?? null)}
defaultStatefulCodeEnvironment={defaultStatefulCodeEnvironment}
/>
</div>
{agent_id && (
@ -588,7 +590,7 @@ export default function AgentPanel() {
variant="outline"
className="w-full justify-center"
onClick={() => {
reset(getDefaultAgentFormValues());
reset(getDefaultAgentFormValues(defaultStatefulCodeEnvironment));
setCurrentAgentId(undefined);
}}
disabled={agentQuery.isInitialLoading}

View file

@ -3,8 +3,8 @@ import { EarthIcon } from 'lucide-react';
import { ControlCombobox } from '@librechat/client';
import { useFormContext, Controller } from 'react-hook-form';
import { AgentCapabilities, defaultAgentFormValues } from 'librechat-data-provider';
import type { Agent, AgentCreateParams, StatefulCodeEnvironment } from 'librechat-data-provider';
import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-query';
import type { Agent, AgentCreateParams } from 'librechat-data-provider';
import type { TAgentCapabilities, AgentForm } from '~/common';
import { cn, createProviderOption, processAgentOption, getDefaultAgentFormValues } from '~/utils';
import { useLocalize, useAgentDefaultPermissionLevel } from '~/hooks';
@ -17,11 +17,13 @@ function AgentSelect({
selectedAgentId = null,
setCurrentAgentId,
createMutation,
defaultStatefulCodeEnvironment,
}: {
selectedAgentId: string | null;
agentQuery: QueryObserverResult<Agent>;
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
createMutation: UseMutationResult<Agent, Error, AgentCreateParams>;
defaultStatefulCodeEnvironment: StatefulCodeEnvironment;
}) {
const localize = useLocalize();
const lastSelectedAgent = useRef<string | null>(null);
@ -180,7 +182,7 @@ function AgentSelect({
createMutation.reset();
if (!agentExists) {
setCurrentAgentId(undefined);
return reset(getDefaultAgentFormValues());
return reset(getDefaultAgentFormValues(defaultStatefulCodeEnvironment));
}
setCurrentAgentId(selectedId);
@ -192,7 +194,15 @@ function AgentSelect({
resetAgentForm(agent);
},
[agents, createMutation, setCurrentAgentId, agentQuery.data, resetAgentForm, reset],
[
agents,
createMutation,
setCurrentAgentId,
agentQuery.data,
resetAgentForm,
reset,
defaultStatefulCodeEnvironment,
],
);
useEffect(() => {

View file

@ -14,8 +14,8 @@ import type { Agent, AgentCreateParams } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import { logger, getDefaultAgentFormValues } from '~/utils';
import { useDeleteAgentMutation } from '~/data-provider';
import { useAuthContext, useLocalize } from '~/hooks';
import { isEphemeralAgent } from '~/common';
import { useLocalize } from '~/hooks';
import store from '~/store';
function DeleteButton({
@ -28,6 +28,7 @@ function DeleteButton({
createMutation: UseMutationResult<Agent, Error, AgentCreateParams>;
}) {
const localize = useLocalize();
const { user } = useAuthContext();
const { reset } = useFormContext();
const { showToast } = useToastContext();
const setConversation = useSetRecoilState(store.conversationByIndex(0));
@ -53,7 +54,7 @@ function DeleteButton({
const firstAgent = updatedList[0] as Agent | undefined;
if (!firstAgent) {
setCurrentAgentId(undefined);
reset(getDefaultAgentFormValues());
reset(getDefaultAgentFormValues(user?.personalization?.statefulCodeEnvironment ?? 'user'));
setConversation((prev) => (prev ? { ...prev, agent_id: '' } : prev));
return;
}

View file

@ -1,7 +1,7 @@
import { useResetRecoilState, useSetRecoilState } from 'recoil';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { MutationKeys, QueryKeys, dataService, request } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
import type * as t from 'librechat-data-provider';
import useClearStates from '~/hooks/Config/useClearStates';
import { clearAllConversationStorage } from '~/utils';
@ -92,6 +92,43 @@ export const useDeleteUserMutation = (
});
};
export const useUpdateUserPreferencesMutation = (
options?: UseMutationOptions<
t.TUpdateUserPreferencesResponse,
Error,
t.TUpdateUserPreferencesRequest
>,
): UseMutationResult<
t.TUpdateUserPreferencesResponse,
Error,
t.TUpdateUserPreferencesRequest,
unknown
> => {
const queryClient = useQueryClient();
return useMutation<t.TUpdateUserPreferencesResponse, Error, t.TUpdateUserPreferencesRequest>(
[MutationKeys.updateUserPreferences],
(preferences: t.TUpdateUserPreferencesRequest) =>
dataService.updateUserPreferences(preferences),
{
...options,
onSuccess: (data, ...args) => {
queryClient.setQueryData<t.TUser>([QueryKeys.user], (user) =>
user
? {
...user,
personalization: {
...user.personalization,
...data.preferences,
},
}
: user,
);
options?.onSuccess?.(data, ...args);
},
},
);
};
export const useEnableTwoFactorMutation = (): UseMutationResult<
t.TEnable2FAResponse,
unknown,

View file

@ -1920,6 +1920,7 @@
"com_ui_settings_section_langfuse": "Langfuse",
"com_ui_settings_section_layout": "Layout",
"com_ui_settings_section_memory": "Memory",
"com_ui_settings_section_code_execution": "Code execution",
"com_ui_settings_section_messages": "Messages",
"com_ui_settings_section_profile": "Profile",
"com_ui_settings_section_prompts": "Prompts",
@ -2066,6 +2067,8 @@
"com_ui_stateful_code_environment_user": "User workspace (recommended)",
"com_ui_stateful_code_environment_agent_user": "Agent + user workspace",
"com_ui_stateful_code_environment_conversation": "Conversation workspace",
"com_ui_default_stateful_workspace": "Default stateful workspace",
"com_ui_default_stateful_workspace_description": "Applied when you create a new agent. Existing agents keep their current workspace scope.",
"com_ui_status_prefix": "Status:",
"com_ui_steer": "Steer",
"com_ui_steer_already_applied": "That steering message already reached the agent, so it was left in the response",

View file

@ -0,0 +1,15 @@
import { getDefaultAgentFormValues } from './forms';
describe('getDefaultAgentFormValues', () => {
beforeEach(() => {
localStorage.clear();
});
it('uses the scalable user workspace by default', () => {
expect(getDefaultAgentFormValues().stateful_code_environment).toBe('user');
});
it('seeds a new agent with the user workspace preference', () => {
expect(getDefaultAgentFormValues('agent-user').stateful_code_environment).toBe('agent-user');
});
});

View file

@ -7,7 +7,7 @@ import {
LocalStorageKeys,
defaultAgentFormValues,
} from 'librechat-data-provider';
import type { Agent, TFile } from 'librechat-data-provider';
import type { Agent, TFile, StatefulCodeEnvironment } from 'librechat-data-provider';
import type { DropdownValueSetter, TAgentOption, ExtendedFile } from '~/common';
/**
@ -48,8 +48,11 @@ export const createProviderOption = (provider: string) => ({
* Gets default agent form values with localStorage values for model and provider.
* This is used to initialize agent forms with the last used model and provider.
**/
export const getDefaultAgentFormValues = () => ({
export const getDefaultAgentFormValues = (
statefulCodeEnvironment: StatefulCodeEnvironment = 'user',
) => ({
...defaultAgentFormValues,
stateful_code_environment: statefulCodeEnvironment,
model: localStorage.getItem(LocalStorageKeys.LAST_AGENT_MODEL) ?? '',
provider: createProviderOption(localStorage.getItem(LocalStorageKeys.LAST_AGENT_PROVIDER) ?? ''),
avatar_file: null,

View file

@ -60,6 +60,8 @@ export * from './conversations';
/* Skills */
export * from './skills';
export * from './favorites';
/* User */
export * from './user';
/* Agent Plugins */
export * from './plugins';
/* Endpoints */

View file

@ -0,0 +1 @@
export * from './preferences';

View file

@ -0,0 +1,98 @@
import type { IUser } from '@librechat/data-schemas';
import type { Request, Response } from 'express';
import { createUserPreferencesHandler } from './preferences';
interface MockResponse extends Partial<Response> {
statusCode: number;
body?: object;
status: jest.Mock;
json: jest.Mock;
}
function createResponse(): MockResponse {
const response: MockResponse = {
statusCode: 200,
status: jest.fn((statusCode: number) => {
response.statusCode = statusCode;
return response;
}),
json: jest.fn((body: object) => {
response.body = body;
return response;
}),
};
return response;
}
function createRequest(body: object, authenticated = true): Request & { user?: Partial<IUser> } {
return {
body,
user: authenticated ? { id: 'user-1' } : undefined,
} as Request & { user?: Partial<IUser> };
}
describe('createUserPreferencesHandler', () => {
it.each(['user', 'agent-user', 'conversation'] as const)(
'persists the %s stateful workspace default',
async (statefulCodeEnvironment) => {
const updateStatefulCodeEnvironment = jest.fn().mockResolvedValue({
personalization: { statefulCodeEnvironment },
});
const handler = createUserPreferencesHandler({ updateStatefulCodeEnvironment });
const response = createResponse();
await handler(
createRequest({ statefulCodeEnvironment }) as Parameters<typeof handler>[0],
response as Response,
);
expect(updateStatefulCodeEnvironment).toHaveBeenCalledWith('user-1', statefulCodeEnvironment);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
updated: true,
preferences: { statefulCodeEnvironment },
});
},
);
it('rejects an invalid stateful workspace default', async () => {
const updateStatefulCodeEnvironment = jest.fn();
const handler = createUserPreferencesHandler({ updateStatefulCodeEnvironment });
const response = createResponse();
await handler(
createRequest({ statefulCodeEnvironment: 'agent' }) as Parameters<typeof handler>[0],
response as Response,
);
expect(updateStatefulCodeEnvironment).not.toHaveBeenCalled();
expect(response.statusCode).toBe(400);
});
it('requires an authenticated user', async () => {
const updateStatefulCodeEnvironment = jest.fn();
const handler = createUserPreferencesHandler({ updateStatefulCodeEnvironment });
const response = createResponse();
await handler(
createRequest({ statefulCodeEnvironment: 'user' }, false) as Parameters<typeof handler>[0],
response as Response,
);
expect(updateStatefulCodeEnvironment).not.toHaveBeenCalled();
expect(response.statusCode).toBe(401);
});
it('returns not found when the user no longer exists', async () => {
const updateStatefulCodeEnvironment = jest.fn().mockResolvedValue(null);
const handler = createUserPreferencesHandler({ updateStatefulCodeEnvironment });
const response = createResponse();
await handler(
createRequest({ statefulCodeEnvironment: 'user' }) as Parameters<typeof handler>[0],
response as Response,
);
expect(response.statusCode).toBe(404);
});
});

View file

@ -0,0 +1,60 @@
import { logger } from '@librechat/data-schemas';
import { STATEFUL_CODE_ENVIRONMENTS } from 'librechat-data-provider';
import type { StatefulCodeEnvironment } from 'librechat-data-provider';
import type { IUser } from '@librechat/data-schemas';
import type { Request, Response } from 'express';
interface UserPreferencesBody {
statefulCodeEnvironment?: string;
}
function isStatefulCodeEnvironment(value: string): value is StatefulCodeEnvironment {
return STATEFUL_CODE_ENVIRONMENTS.some((environment) => environment === value);
}
type UserPreferencesRequest = Request<unknown, unknown, UserPreferencesBody> & {
user?: IUser;
};
export interface UserPreferencesHandlerDeps {
updateStatefulCodeEnvironment: (
userId: string,
environment: StatefulCodeEnvironment,
) => Promise<IUser | null>;
}
export function createUserPreferencesHandler(
deps: UserPreferencesHandlerDeps,
): (req: UserPreferencesRequest, res: Response) => Promise<Response> {
return async (req: UserPreferencesRequest, res: Response): Promise<Response> => {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ message: 'Unauthorized' });
}
const environment = req.body?.statefulCodeEnvironment;
if (typeof environment !== 'string' || !isStatefulCodeEnvironment(environment)) {
return res.status(400).json({
message: `statefulCodeEnvironment must be one of: ${STATEFUL_CODE_ENVIRONMENTS.join(', ')}`,
});
}
try {
const updatedUser = await deps.updateStatefulCodeEnvironment(userId, environment);
if (!updatedUser) {
return res.status(404).json({ message: 'User not found' });
}
return res.status(200).json({
updated: true,
preferences: {
statefulCodeEnvironment:
updatedUser.personalization?.statefulCodeEnvironment ?? environment,
},
});
} catch (error) {
logger.error('[UserPreferences] Error updating preferences:', error);
return res.status(500).json({ message: 'Failed to update user preferences' });
}
};
}

View file

@ -42,6 +42,7 @@ const buildQuery = (params: Record<string, unknown>): string => {
export const health = () => `${BASE_URL}/health`;
export const user = () => `${BASE_URL}/api/user`;
export const userPreferences = () => `${user()}/preferences`;
export const balance = () => `${BASE_URL}/api/balance`;

View file

@ -172,6 +172,12 @@ export function getUser(): Promise<t.TUser> {
return request.get(endpoints.user());
}
export function updateUserPreferences(
preferences: t.TUpdateUserPreferencesRequest,
): Promise<t.TUpdateUserPreferencesResponse> {
return request.patch(endpoints.userPreferences(), preferences);
}
export function getUserBalance(): Promise<t.TBalanceResponse> {
return request.get(endpoints.balance());
}

View file

@ -119,6 +119,7 @@ export enum MutationKeys {
deleteAgentAction = 'deleteAgentAction',
revertAgentVersion = 'revertAgentVersion',
deleteUser = 'deleteUser',
updateUserPreferences = 'updateUserPreferences',
updateRole = 'updateRole',
enableTwoFactor = 'enableTwoFactor',
verifyTwoFactor = 'verifyTwoFactor',

View file

@ -10,7 +10,7 @@ import type {
ReasoningResponseKey,
ReasoningParameterFormat,
} from './schemas';
import type { Agent, EToolResources } from './types/assistants';
import type { Agent, EToolResources, StatefulCodeEnvironment } from './types/assistants';
import type { RefillIntervalUnit } from './balance';
import type { SettingDefinition } from './generate';
import type { TMinimalFeedback } from './feedback';
@ -293,11 +293,21 @@ export type TUser = {
backupCodes?: TBackupCode[];
personalization?: {
memories?: boolean;
statefulCodeEnvironment?: StatefulCodeEnvironment;
};
createdAt: string;
updatedAt: string;
};
export type TUpdateUserPreferencesRequest = {
statefulCodeEnvironment: StatefulCodeEnvironment;
};
export type TUpdateUserPreferencesResponse = {
updated: boolean;
preferences: TUpdateUserPreferencesRequest;
};
export type TGetConversationsResponse = {
conversations: TConversation[];
pageNumber: string;

View file

@ -538,7 +538,8 @@ export enum AnnotationTypes {
FILE_PATH = 'file_path',
}
export type StatefulCodeEnvironment = 'user' | 'agent-user' | 'conversation';
export const STATEFUL_CODE_ENVIRONMENTS = ['user', 'agent-user', 'conversation'] as const;
export type StatefulCodeEnvironment = (typeof STATEFUL_CODE_ENVIRONMENTS)[number];
export enum StepStatus {
IN_PROGRESS = 'in_progress',

View file

@ -106,6 +106,27 @@ describe('User schema indexes', () => {
});
});
describe('User personalization', () => {
test('defaults new users to the shared user workspace', async () => {
const user = await User.create({
email: 'stateful-default@example.com',
provider: 'local',
});
expect(user.personalization?.statefulCodeEnvironment).toBe('user');
});
test('rejects unsupported stateful workspace defaults', async () => {
await expect(
User.create({
email: 'invalid-stateful-default@example.com',
provider: 'local',
personalization: { statefulCodeEnvironment: 'agent' },
}),
).rejects.toThrow();
});
});
describe('User Methods - Database Tests', () => {
describe('findUser', () => {
test('should find user by exact email', async () => {
@ -829,6 +850,57 @@ describe('User Methods - Database Tests', () => {
});
});
describe('updateUserStatefulCodeEnvironment', () => {
test('updates the workspace default without changing memory preferences', async () => {
const user = await User.create({
email: 'stateful-preference@example.com',
provider: 'local',
personalization: { memories: false, statefulCodeEnvironment: 'user' },
});
const updated = await methods.updateUserStatefulCodeEnvironment(
user._id?.toString() ?? '',
'agent-user',
);
expect(updated?.personalization).toMatchObject({
memories: false,
statefulCodeEnvironment: 'agent-user',
});
});
test('returns null for a missing user', async () => {
const userId = new mongoose.Types.ObjectId().toString();
await expect(
methods.updateUserStatefulCodeEnvironment(userId, 'conversation'),
).resolves.toBeNull();
});
test('invalidates cached auth user documents', async () => {
enableAuthUserDocCache();
const user = await User.create({
email: 'cached-stateful-preference@example.com',
provider: 'openid',
});
const userId = user._id?.toString() ?? '';
const indexKey = `${AUTH_USER_DOC_BY_ID_PREFIX}:${userId}`;
const cache = {
get: jest.fn().mockResolvedValue(['auth-cache-key-a']),
delete: jest.fn().mockResolvedValue(true),
};
const methodsWithCache = createUserMethods(mongoose, {
getCache: jest.fn().mockReturnValue(cache),
});
await methodsWithCache.updateUserStatefulCodeEnvironment(userId, 'conversation');
expect(cache.get).toHaveBeenCalledWith(indexKey);
expect(cache.delete).toHaveBeenCalledWith('auth-cache-key-a');
expect(cache.delete).toHaveBeenCalledWith(indexKey);
});
});
describe('Email Normalization Edge Cases', () => {
test('should handle email with multiple spaces', async () => {
await User.create({

View file

@ -3,6 +3,7 @@ import {
AUTH_USER_DOC_BY_ID_PREFIX,
CacheKeys,
type RefillIntervalUnit,
type StatefulCodeEnvironment,
} from 'librechat-data-provider';
import type { IUser, BalanceConfig, CreateUserRequest, UserDeleteResult } from '~/types';
import type { CacheStore } from '~/types';
@ -93,6 +94,7 @@ export function createUserMethods(
termsAccepted?: boolean;
personalization?: {
memories?: boolean;
statefulCodeEnvironment?: import('librechat-data-provider').StatefulCodeEnvironment;
};
favorites?: import('librechat-data-provider').TUserFavorite[];
skillStates?: Record<string, boolean>;
@ -123,6 +125,10 @@ export function createUserMethods(
action: 'install' | 'uninstall',
) => Promise<IUser | null>;
toggleUserMemories: (userId: string, memoriesEnabled: boolean) => Promise<IUser | null>;
updateUserStatefulCodeEnvironment: (
userId: string,
environment: StatefulCodeEnvironment,
) => Promise<IUser | null>;
} {
/**
* Normalizes email fields in search criteria to lowercase and trimmed.
@ -427,6 +433,22 @@ export function createUserMethods(
return updated;
}
async function updateUserStatefulCodeEnvironment(
userId: string,
environment: StatefulCodeEnvironment,
): Promise<IUser | null> {
const User = mongoose.models.User;
const updated = await User.findByIdAndUpdate(
userId,
{ $set: { 'personalization.statefulCodeEnvironment': environment } },
{ new: true, runValidators: true },
).lean<IUser>();
if (updated) {
await invalidateAuthUserDocCache(userId);
}
return updated;
}
/**
* Search for users by pattern matching on name, email, or username (case-insensitive)
* @param searchPattern - The pattern to search for
@ -484,6 +506,7 @@ export function createUserMethods(
termsAccepted?: boolean;
personalization?: {
memories?: boolean;
statefulCodeEnvironment?: import('librechat-data-provider').StatefulCodeEnvironment;
};
favorites?: import('librechat-data-provider').TUserFavorite[];
skillStates?: Record<string, boolean>;
@ -607,6 +630,7 @@ export function createUserMethods(
deleteUserById,
updateUserPlugins,
toggleUserMemories,
updateUserStatefulCodeEnvironment,
};
}

View file

@ -1,5 +1,5 @@
import { Schema } from 'mongoose';
import { SystemRoles } from 'librechat-data-provider';
import { SystemRoles, STATEFUL_CODE_ENVIRONMENTS } from 'librechat-data-provider';
import { IUser } from '~/types';
// Session sub-schema
@ -137,6 +137,11 @@ const userSchema: Schema<IUser> = new Schema<IUser>(
type: Boolean,
default: true,
},
statefulCodeEnvironment: {
type: String,
enum: STATEFUL_CODE_ENVIRONMENTS,
default: 'user',
},
},
default: {},
},

View file

@ -1,4 +1,8 @@
import type { RefillIntervalUnit, TUserFavorite } from 'librechat-data-provider';
import type {
TUserFavorite,
RefillIntervalUnit,
StatefulCodeEnvironment,
} from 'librechat-data-provider';
import type { Document, Types } from 'mongoose';
import { CursorPaginationParams } from '~/common';
@ -51,6 +55,7 @@ export interface IUser extends Document {
termsAcceptedAt?: Date | null;
personalization?: {
memories?: boolean;
statefulCodeEnvironment?: StatefulCodeEnvironment;
};
favorites?: TUserFavorite[];
/** Per-skill active/inactive overrides. Key = skillId, value = active state. */
@ -97,6 +102,7 @@ export interface UpdateUserRequest {
termsAcceptedAt?: Date | null;
personalization?: {
memories?: boolean;
statefulCodeEnvironment?: StatefulCodeEnvironment;
};
skillStates?: Record<string, boolean>;
}