diff --git a/api/server/controllers/agents/__tests__/callbacks.spec.js b/api/server/controllers/agents/__tests__/callbacks.spec.js
index 6423c6b4ce1..2ffcc79952d 100644
--- a/api/server/controllers/agents/__tests__/callbacks.spec.js
+++ b/api/server/controllers/agents/__tests__/callbacks.spec.js
@@ -33,6 +33,7 @@ jest.mock('@librechat/api', () => ({
isCodeArtifactToolOutput: jest.requireActual('@librechat/api').isCodeArtifactToolOutput,
isCodeSessionToolName: jest.requireActual('@librechat/api').isCodeSessionToolName,
collectToolCallIds: jest.requireActual('@librechat/api').collectToolCallIds,
+ stampCommandExecutor: jest.requireActual('@librechat/api').stampCommandExecutor,
}));
jest.mock('@librechat/data-schemas', () => ({
diff --git a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js
index b725dd45cf8..05f4e55b7d2 100644
--- a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js
+++ b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js
@@ -49,6 +49,8 @@ jest.mock('@librechat/data-schemas', () => ({
jest.mock('@librechat/api', () => ({
getAgentErrorMetadata: (...args) =>
jest.requireActual('@librechat/api').getAgentErrorMetadata(...args),
+ applyForcedTemporaryRequest: jest.fn(),
+ resolveResumableRetention: jest.requireActual('@librechat/api').resolveResumableRetention,
sendEvent: jest.fn(),
persistedReasoningOverrideFields:
jest.requireActual('@librechat/api').persistedReasoningOverrideFields,
diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
index 0981b5fa629..f4090c67661 100644
--- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
+++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
@@ -252,6 +252,7 @@ const mockCleanupMCPRequestContextForReq = jest.fn(async (req) => {
jest.mock('@librechat/data-schemas', () => ({
logger: mockLogger,
+ createChatExpirationDate: jest.requireActual('@librechat/data-schemas').createChatExpirationDate,
}));
jest.mock('@librechat/api', () => ({
@@ -260,6 +261,8 @@ jest.mock('@librechat/api', () => ({
).getSteerRecoveryFailure,
getAgentErrorMetadata: (...args) =>
jest.requireActual('@librechat/api').getAgentErrorMetadata(...args),
+ applyForcedTemporaryRequest: jest.fn(),
+ resolveResumableRetention: jest.requireActual('@librechat/api').resolveResumableRetention,
sendEvent: jest.fn(),
/** Real, because whether a skipped-persistence turn may raise an indicator is under test. */
isAnnounceableReply: jest.requireActual('@librechat/api').isAnnounceableReply,
@@ -1011,6 +1014,39 @@ describe('ResumableAgentController resume metadata', () => {
expect(mockStartupTelemetry.end).toHaveBeenCalledWith('error', expect.any(Error));
});
+ it('records a forced-temporary run as temporary, with a deadline, whatever the client sent', async () => {
+ const conversationId = 'conversation-ephemeral';
+ const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
+ const req = {
+ user: { id: 'user-123' },
+ body: {
+ text: 'Hello',
+ messageId: 'user-message',
+ parentMessageId: 'parent-message',
+ conversationId,
+ isTemporary: false,
+ endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-3.5-turbo' } },
+ },
+ config: { interfaceConfig: { retentionMode: 'ephemeral', temporaryChatRetention: 1 } },
+ };
+ const res = {
+ headersSent: true,
+ json: jest.fn(() => {
+ res.headersSent = true;
+ }),
+ status: jest.fn(() => res),
+ };
+
+ await AgentController(req, res, jest.fn(), initializeClient, null);
+
+ expect(require('@librechat/api').applyForcedTemporaryRequest).toHaveBeenCalledWith(req);
+ const [, , , options] = mockGenerationJobManager.createJob.mock.calls.at(-1);
+ expect(options.initialMetadata.isTemporary).toBe(true);
+ expect(new Date(options.initialMetadata.retentionExpiresAt).getTime()).toBeGreaterThan(
+ Date.now(),
+ );
+ });
+
it('persists and exactly echoes protocol v2 on a newly created generation', async () => {
mockGenerationJobManager.createJob.mockResolvedValue({
createdAt: 1000,
diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js
index 15bcdfa0b6f..aa079c64b46 100644
--- a/api/server/controllers/agents/__tests__/responses.unit.spec.js
+++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js
@@ -200,6 +200,9 @@ jest.mock('@librechat/agents', () => ({
jest.mock('@librechat/api', () => ({
getAgentErrorMetadata: (...args) =>
jest.requireActual('@librechat/api').getAgentErrorMetadata(...args),
+ getConversationWriteContext: (...args) =>
+ jest.requireActual('@librechat/api').getConversationWriteContext(...args),
+ announceReply: jest.fn().mockResolvedValue(undefined),
/* Provisioning moved into this package; the controllers build the callback from it. */
createProvisionFilesCallback: () => async () => {},
createAgentExecutionContext: (context) => context,
@@ -1040,7 +1043,7 @@ describe('createResponse controller', () => {
expect(api.getLangfuseTraceMessageFields).toHaveBeenCalledWith(req.config, 'resp_mock-123');
expect(saveMessage).toHaveBeenCalledWith(
- req,
+ expect.objectContaining({ userId: 'user-123' }),
expect.objectContaining({
messageId: 'resp_mock-123',
isCreatedByUser: false,
@@ -1052,6 +1055,41 @@ describe('createResponse controller', () => {
);
});
+ it.each([false, true])(
+ 'stores input and output under the retention write context: stream=%s',
+ async (stream) => {
+ const api = require('@librechat/api');
+ const db = require('~/models');
+ req.config.interfaceConfig = { retentionMode: 'ephemeral', temporaryChatRetention: 1 };
+ req.body.isTemporary = false;
+ api.validateResponseRequest.mockReturnValueOnce({
+ request: { ...req.body, stream, store: true },
+ });
+ api.convertInputToMessages.mockReturnValueOnce([
+ { role: 'user', content: 'Hello', messageId: 'input-123' },
+ ]);
+ const savedResponse = { messageId: 'resp_mock-123', isTemporary: true };
+ db.saveMessage.mockResolvedValueOnce({ messageId: 'input-123' });
+ db.saveMessage.mockResolvedValueOnce(savedResponse);
+
+ await createResponse(req, res);
+
+ expect(db.saveMessage).toHaveBeenCalledTimes(2);
+ for (const [context] of db.saveMessage.mock.calls) {
+ expect(context).toEqual({
+ userId: 'user-123',
+ isTemporary: false,
+ expiredAt: undefined,
+ interfaceConfig: req.config.interfaceConfig,
+ });
+ }
+ expect(api.announceReply).toHaveBeenCalledWith(
+ db,
+ expect.objectContaining({ reply: savedResponse }),
+ );
+ },
+ );
+
describe('execution envelope', () => {
it('creates the portable run input before agent initialization', async () => {
req.user = {
diff --git a/api/server/controllers/agents/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js
index cf0ebef3f14..653c3c78b99 100644
--- a/api/server/controllers/agents/__tests__/resume.spec.js
+++ b/api/server/controllers/agents/__tests__/resume.spec.js
@@ -96,6 +96,7 @@ const mockCheckAccess = jest.fn();
const mockCheckPermission = jest.fn();
const mockDecryptMetadata = jest.fn();
const mockStampConvoLastResponse = jest.fn().mockResolvedValue(undefined);
+const mockStampForcedRetention = jest.fn().mockResolvedValue(undefined);
const mockDisposeClient = jest.fn();
const mockGetMCPRequestContext = jest.fn();
const mockCleanupMCPRequestContextForReq = jest.fn();
@@ -152,6 +153,7 @@ jest.mock('@librechat/api', () => ({
jest.mock('~/models', () => ({
saveMessage: (...args) => mockSaveMessage(...args),
+ stampForcedRetention: (...args) => mockStampForcedRetention(...args),
getConvo: (...args) => mockGetConvo(...args),
getChatProject: (...args) => mockGetChatProject(...args),
getMessages: (...args) => mockGetMessages(...args),
@@ -3237,6 +3239,94 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
expect(capturedInit.files).toEqual([{ file_id: 'f1' }]);
});
+ it.each([false, true])(
+ 'converts a pre-policy paused chat when re-pause=%s',
+ async (rePause) => {
+ requestConfigOverrides.interfaceConfig = {
+ retentionMode: 'ephemeral',
+ temporaryChatRetention: 1,
+ };
+ mockGenerationJobManager.getJob.mockResolvedValue(
+ makeToolApprovalJob({ metadata: { isTemporary: false } }),
+ );
+ mockSaveMessage.mockImplementation(async (_ctx, message) => message);
+ if (rePause) {
+ mockInitializeClient.mockResolvedValue({
+ client: makeClient({
+ pendingApproval: { actionId: NEXT_ACTION_ID },
+ contentParts: [{ type: 'text', text: 'partial' }],
+ }),
+ userMCPAuthMap: {},
+ });
+ }
+
+ const res = await post(approveBody({ isTemporary: false }));
+ expect(res.status).toBe(200);
+ await settled;
+ await flush();
+
+ expect(mockInitializeClient.mock.calls[0][0].req.body.isTemporary).toBe(true);
+ expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
+ CONVO_ID,
+ { isTemporary: true },
+ 1000,
+ );
+ expect(mockGenerationJobManager.updateMetadata.mock.invocationCallOrder[0]).toBeLessThan(
+ mockInitializeClient.mock.invocationCallOrder[0],
+ );
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ isTemporary: true }),
+ expect.anything(),
+ expect.anything(),
+ );
+ expect(mockStampForcedRetention).toHaveBeenCalledWith(
+ { userId: USER_ID, interfaceConfig: requestConfigOverrides.interfaceConfig },
+ { conversationId: CONVO_ID, messageIds: [mockSaveMessage.mock.calls[0][1].messageId] },
+ );
+ expect(mockSaveMessage.mock.calls[0][1].messageId).toEqual(expect.any(String));
+ expect(mockStampForcedRetention.mock.calls[0][1]).toEqual({
+ conversationId: CONVO_ID,
+ messageIds: [],
+ });
+ expect(mockStampForcedRetention.mock.invocationCallOrder[0]).toBeLessThan(
+ mockInitializeClient.mock.invocationCallOrder[0],
+ );
+ const messageStampOrder = mockStampForcedRetention.mock.invocationCallOrder.at(-1);
+ expect(mockSaveMessage.mock.invocationCallOrder[0]).toBeLessThan(messageStampOrder);
+ const publication = rePause
+ ? mockGenerationJobManager.approvals.finishPausePersistence
+ : mockGenerationJobManager.publishTerminalClaim;
+ expect(messageStampOrder).toBeLessThan(publication.mock.invocationCallOrder[0]);
+ expect(mockStampConvoLastResponse).not.toHaveBeenCalled();
+ expect(mockAddTitle).not.toHaveBeenCalled();
+ },
+ );
+
+ it('converts a pre-policy paused chat that re-pauses without new output', async () => {
+ requestConfigOverrides.interfaceConfig = {
+ retentionMode: 'ephemeral',
+ temporaryChatRetention: 1,
+ };
+ mockGenerationJobManager.getJob.mockResolvedValue(
+ makeToolApprovalJob({ metadata: { isTemporary: false } }),
+ );
+ mockInitializeClient.mockResolvedValue({
+ client: makeClient({ pendingApproval: { actionId: NEXT_ACTION_ID }, contentParts: [] }),
+ userMCPAuthMap: {},
+ });
+
+ const res = await post(approveBody({ isTemporary: false }));
+ expect(res.status).toBe(200);
+ await settled;
+ await flush();
+
+ expect(mockSaveMessage).not.toHaveBeenCalled();
+ expect(mockStampForcedRetention).toHaveBeenCalledWith(
+ { userId: USER_ID, interfaceConfig: requestConfigOverrides.interfaceConfig },
+ { conversationId: CONVO_ID, messageIds: [] },
+ );
+ });
+
it.each([false, true])('preserves the job deadline when re-pause=%s', async (rePause) => {
const expiredAt = new Date('2030-01-01T00:00:00.000Z');
mockGenerationJobManager.getJob.mockResolvedValue(
diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js
index fa656d53ae3..04f1ec40a06 100644
--- a/api/server/controllers/agents/request.js
+++ b/api/server/controllers/agents/request.js
@@ -11,6 +11,8 @@ const {
toPendingSteer,
persistedReasoningOverrideFields,
getViolationInfo,
+ applyForcedTemporaryRequest,
+ resolveResumableRetention,
buildMessageFiles,
getReferencedQuotes,
resolveTitleTiming,
@@ -748,6 +750,7 @@ function rejectMissingTriggerParentMessageId(res, generationProtocolVersion) {
* Returns streamId immediately, client subscribes separately via SSE.
*/
const ResumableAgentController = async (req, res, next, initializeClient, addTitle) => {
+ applyForcedTemporaryRequest(req);
const startupTelemetry = getAgentStartupTelemetry(req);
let generationProtocolVersion = negotiateNewGenerationProtocol(req);
const {
@@ -1679,24 +1682,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
agent_id: endpointOption.agent_id ?? req.body?.agent_id,
// Persist temporary-chat state so a HITL resume keeps the resumed response
// non-persisted instead of trusting the resume request to re-send the flag.
- isTemporary:
- req._agentEventBindingRetention?.isTemporary ??
- req.resolvedConversation?.isTemporary ??
- req.body?.isTemporary,
- ...((req._agentEventBindingRetention?.expiredAt ?? req.resolvedConversation?.expiredAt) !=
- null && {
- retentionExpiresAt: new Date(
- req._agentEventBindingRetention?.expiredAt ?? req.resolvedConversation.expiredAt,
- ).toISOString(),
- }),
- ...((req._agentEventBindingRetention?.expiredAt ?? req.resolvedConversation?.expiredAt) ==
- null &&
- req.config?.interfaceConfig?.retentionMode === 'all' && {
- retentionExpiresAt: createChatExpirationDate(
- req.config.interfaceConfig,
- req.resolvedConversation?.isTemporary ?? req.body?.isTemporary,
- ).toISOString(),
- }),
+ ...resolveResumableRetention(req, createChatExpirationDate),
...(agentEventDelivery != null && {
agentEventDeliveryKey: agentEventDelivery.deliveryKey,
...(internalDetachedCompletion == null
diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js
index 13917a58a11..7daf8478b84 100644
--- a/api/server/controllers/agents/responses.js
+++ b/api/server/controllers/agents/responses.js
@@ -90,6 +90,7 @@ const {
resolvePersistableCodeEnvironmentDecision,
createTerminalRunErrorObserver,
announceReply,
+ getConversationWriteContext,
} = require('@librechat/api');
const {
createResponsesToolEndCallback,
@@ -375,7 +376,7 @@ async function saveInputMessages(req, conversationId, inputMessages, agentId) {
for (const msg of inputMessages) {
if (msg.role === 'user') {
await db.saveMessage(
- req,
+ getConversationWriteContext(req),
{
messageId: msg.messageId || nanoid(),
conversationId,
@@ -426,7 +427,7 @@ async function saveResponseOutput(
// Save the assistant message
return db.saveMessage(
- req,
+ getConversationWriteContext(req),
{
messageId: responseId,
conversationId,
@@ -456,12 +457,7 @@ async function saveResponseOutput(
async function saveConversation(req, conversationId, agentId, agent, codeEnvironmentDecision) {
const title = resolveConversationTitle(req, agent?.name || 'Open Responses Conversation');
await db.saveConvo(
- {
- userId: req?.user?.id,
- isTemporary: req?.resolvedConversation?.isTemporary ?? req?.body?.isTemporary,
- expiredAt: req?.resolvedConversation?.expiredAt,
- interfaceConfig: req?.config?.interfaceConfig,
- },
+ getConversationWriteContext(req),
{
conversationId,
endpoint: EModelEndpoint.agents,
@@ -1453,10 +1449,7 @@ const executeResponse = async (envelope, { req, res }) => {
await announceReply(db, {
userId: req?.user?.id,
conversationId,
- reply: {
- ...savedResponse,
- isTemporary: req?.resolvedConversation?.isTemporary ?? req?.body?.isTemporary,
- },
+ reply: savedResponse,
context: 'Responses API - announce stored reply',
});
@@ -1696,10 +1689,7 @@ const executeResponse = async (envelope, { req, res }) => {
await announceReply(db, {
userId: req?.user?.id,
conversationId,
- reply: {
- ...savedResponse,
- isTemporary: req?.resolvedConversation?.isTemporary ?? req?.body?.isTemporary,
- },
+ reply: savedResponse,
context: 'Responses API - announce stored reply',
});
diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js
index 3cffe9b151b..c162b05216a 100644
--- a/api/server/controllers/agents/resume.js
+++ b/api/server/controllers/agents/resume.js
@@ -52,6 +52,9 @@ const {
PROJECT_CONTEXT_CHANGED_RESPONSE,
restoreScheduledTokenContext,
recoverTurnMessageReference,
+ applyForcedRetention,
+ applyForcedTemporaryRequest,
+ persistForcedTemporaryMetadata,
announceReply,
} = require('@librechat/api');
const { disposeClient } = require('~/server/cleanup');
@@ -86,6 +89,7 @@ const {
settleAgentEventActorDetachedAction,
appendConvoMessageReference,
stampConvoLastResponse,
+ stampForcedRetention,
} = require('~/models');
const {
acquireEventChildGenerationLease,
@@ -363,6 +367,14 @@ async function persistRePauseProgress({ req, client, job, streamId, conversation
if (!savedResponseMessage) {
throw new Error('Re-pause response progress could not be persisted');
}
+ await applyForcedRetention(
+ { stampForcedRetention },
+ {
+ ctx: { userId, interfaceConfig: req.config?.interfaceConfig },
+ conversationId,
+ messageId: savedResponseMessage.messageId,
+ },
+ );
await recoverResumedResponseReference(
{ userId, conversationId, client, savedResponseMessage },
'api/server/controllers/agents/resume.js - recovered re-paused response reference',
@@ -563,6 +575,14 @@ async function finalizeResumedTurn({
if (!savedResponseMessage) {
throw new Error('Resumed response could not be persisted before terminal publication');
}
+ await applyForcedRetention(
+ { stampForcedRetention },
+ {
+ ctx: { userId, interfaceConfig: req.config?.interfaceConfig },
+ conversationId,
+ messageId: savedResponseMessage.messageId,
+ },
+ );
await recoverResumedResponseReference(
{ userId, conversationId, client, savedResponseMessage },
'api/server/controllers/agents/resume.js - recovered resumed response reference',
@@ -1050,6 +1070,7 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
// Rebuild the same persistence/retention mode as the paused turn. The resume body
// is not authoritative: tools inspect this field during client initialization.
req.body.isTemporary = job.metadata.isTemporary === true;
+ applyForcedTemporaryRequest(req, job.metadata);
const metaFiles = job.metadata.userMessage?.files;
if (Array.isArray(metaFiles) && metaFiles.length > 0) {
req.body.files = metaFiles;
@@ -1889,6 +1910,18 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
req.turnStartedAt = job.createdAt;
+ await persistForcedTemporaryMetadata(
+ req,
+ { streamId, createdAt: job.createdAt },
+ GenerationJobManager,
+ );
+ await applyForcedRetention(
+ { stampForcedRetention },
+ {
+ ctx: { userId: req.user.id, interfaceConfig: req.config?.interfaceConfig },
+ conversationId,
+ },
+ );
if (userSubmittedPaths.length > 0) {
job.metadata.userSubmittedPaths = userSubmittedPaths;
}
diff --git a/api/server/controllers/assistants/chat.contentFilter.spec.js b/api/server/controllers/assistants/chat.contentFilter.spec.js
index 2b740837321..3dd07762603 100644
--- a/api/server/controllers/assistants/chat.contentFilter.spec.js
+++ b/api/server/controllers/assistants/chat.contentFilter.spec.js
@@ -280,6 +280,21 @@ describe.each([
expect(mockHandleError).not.toHaveBeenCalled();
expect(mockSendResponse).not.toHaveBeenCalled();
}
+
+ it.each([false, undefined])(
+ 'applies forced temporary retention before assistant initialization (%s)',
+ async (isTemporary) => {
+ req.body.isTemporary = isTemporary;
+ req.config.interfaceConfig = { retentionMode: 'ephemeral', temporaryChatRetention: 1 };
+ mockInitThread.mockRejectedValueOnce(new Error('stop after retention setup'));
+
+ await chatController(req, res);
+
+ expect(mockInitThread).toHaveBeenCalledTimes(1);
+ expect(req.body.isTemporary).toBe(true);
+ },
+ );
+
it('persists the assistant before FINAL and forwards the settled read-state stamp', async () => {
const stamp = new Date('2026-09-08T12:00:00.000Z');
const settledConversation = {
diff --git a/api/server/controllers/assistants/chatV1.js b/api/server/controllers/assistants/chatV1.js
index 95f0d66e2c3..5d3fa97e3d2 100644
--- a/api/server/controllers/assistants/chatV1.js
+++ b/api/server/controllers/assistants/chatV1.js
@@ -19,6 +19,7 @@ const {
settleAssistantFinal,
resolveAssistantProjectTurn,
joinChatProjectInstructions,
+ applyForcedTemporaryRequest,
} = require('@librechat/api');
const {
Time,
@@ -74,6 +75,7 @@ const { getOpenAIClient } = require('./helpers');
* @returns {void}
*/
const chatV1 = async (req, res) => {
+ applyForcedTemporaryRequest(req);
const appConfig = req.config;
const {
diff --git a/api/server/controllers/assistants/chatV2.js b/api/server/controllers/assistants/chatV2.js
index 992dbfdfa9b..c0f3018c763 100644
--- a/api/server/controllers/assistants/chatV2.js
+++ b/api/server/controllers/assistants/chatV2.js
@@ -18,6 +18,7 @@ const {
settleAssistantFinal,
resolveAssistantProjectTurn,
joinChatProjectInstructions,
+ applyForcedTemporaryRequest,
} = require('@librechat/api');
const {
Time,
@@ -67,6 +68,7 @@ const { getOpenAIClient } = require('./helpers');
* @returns {void}
*/
const chatV2 = async (req, res) => {
+ applyForcedTemporaryRequest(req);
const appConfig = req.config;
/** @type {{files: MongoFile[]}} */
diff --git a/api/server/middleware/optionalShareFileAuth.spec.js b/api/server/middleware/optionalShareFileAuth.spec.js
index ffc0cf5cfc9..f68a356abf9 100644
--- a/api/server/middleware/optionalShareFileAuth.spec.js
+++ b/api/server/middleware/optionalShareFileAuth.spec.js
@@ -4,8 +4,9 @@ const mockFindSession = jest.fn();
const mockRunAsSystem = jest.fn((fn) => fn());
jest.mock('jsonwebtoken', () => ({ verify: (...args) => mockVerify(...args) }));
+/** Installed packages need real module IDs when Jest reuses a resolver across suites. */
jest.mock('@librechat/api', () => ({ isEnabled: (v) => v === 'true' || v === true }), {
- virtual: true,
+ virtual: false,
});
jest.mock(
'@librechat/data-schemas',
@@ -13,10 +14,10 @@ jest.mock(
logger: { warn: jest.fn(), error: jest.fn() },
runAsSystem: (...args) => mockRunAsSystem(...args),
}),
- { virtual: true },
+ { virtual: false },
);
jest.mock('librechat-data-provider', () => ({ SystemRoles: { USER: 'USER' } }), {
- virtual: true,
+ virtual: false,
});
jest.mock('~/models', () => ({
getUserById: (...args) => mockGetUserById(...args),
diff --git a/api/server/routes/__tests__/messages-content-edit.spec.js b/api/server/routes/__tests__/messages-content-edit.spec.js
index 9ed509aa45c..29d98761aa6 100644
--- a/api/server/routes/__tests__/messages-content-edit.spec.js
+++ b/api/server/routes/__tests__/messages-content-edit.spec.js
@@ -22,6 +22,7 @@ jest.mock('@librechat/api', () => ({
CHILD_THREAD_READ_ONLY_ERROR: 'Child thread is view-only.',
isSubagentThreadWriteBlocked: jest.fn().mockResolvedValue(false),
requireFeedbackEnabled: (req, res, next) => next(),
+ applyForcedRetention: jest.fn(),
}));
jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({}));
@@ -37,6 +38,8 @@ jest.mock('@librechat/data-schemas', () => ({
}));
jest.mock('~/models', () => ({
+ saveConvo: jest.fn(),
+ saveMessage: jest.fn(),
getMessages: jest.fn(),
updateMessage: jest.fn(),
}));
@@ -56,7 +59,7 @@ jest.mock('~/server/middleware', () => ({
describe('PUT /:conversationId/:messageId content edit', () => {
let app;
- const { getMessages, updateMessage } = require('~/models');
+ const { getMessages, saveConvo, saveMessage, updateMessage } = require('~/models');
const { assertStoredMessageMutationAllowed } = require('@librechat/api');
beforeAll(() => {
@@ -73,6 +76,8 @@ describe('PUT /:conversationId/:messageId content edit', () => {
beforeEach(() => {
jest.clearAllMocks();
updateMessage.mockResolvedValue({ messageId: 'message-1' });
+ saveMessage.mockResolvedValue({ messageId: 'message-1', conversationId: 'conversation-1' });
+ saveConvo.mockResolvedValue({ conversationId: 'conversation-1' });
});
it('preserves content-part metadata when editing its text', async () => {
diff --git a/api/server/routes/__tests__/messages-feedback.spec.js b/api/server/routes/__tests__/messages-feedback.spec.js
index 46d53a3ce06..ca38a895ef5 100644
--- a/api/server/routes/__tests__/messages-feedback.spec.js
+++ b/api/server/routes/__tests__/messages-feedback.spec.js
@@ -14,6 +14,7 @@ jest.mock('@librechat/api', () => ({
CHILD_THREAD_READ_ONLY_ERROR: 'Child thread is view-only.',
isSubagentThreadWriteBlocked: jest.fn().mockResolvedValue(false),
requireFeedbackEnabled: jest.fn((req, res, next) => next()),
+ applyForcedRetention: jest.fn(),
}));
jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({}));
diff --git a/api/server/routes/__tests__/messages-get.spec.js b/api/server/routes/__tests__/messages-get.spec.js
index b72c0973f48..10620582326 100644
--- a/api/server/routes/__tests__/messages-get.spec.js
+++ b/api/server/routes/__tests__/messages-get.spec.js
@@ -59,6 +59,7 @@ jest.mock('@librechat/api', () => {
extractChatContent,
extractFeedbackContent: jest.fn(() => []),
extractStoredMessageContent,
+ applyForcedRetention: jest.fn(),
contentFilterBlockResponse,
getContentTraversalFragments,
isContentTraversalLimitError,
diff --git a/api/server/routes/__tests__/messages-retention.spec.js b/api/server/routes/__tests__/messages-retention.spec.js
new file mode 100644
index 00000000000..fc796580549
--- /dev/null
+++ b/api/server/routes/__tests__/messages-retention.spec.js
@@ -0,0 +1,237 @@
+const express = require('express');
+const request = require('supertest');
+const { RetentionMode } = require('librechat-data-provider');
+
+jest.mock('@librechat/agents', () => ({
+ sleep: jest.fn(),
+}));
+
+jest.mock('@librechat/api', () => ({
+ unescapeLaTeX: jest.fn((value) => value),
+ countTokens: jest.fn().mockResolvedValue(10),
+ createContentFilter: jest.fn(() => (_req, _res, next) => next()),
+ sendFeedbackScore: jest.fn().mockResolvedValue(undefined),
+ traceIdForMessage: jest.fn((messageId) => `trace-${messageId}`),
+ mergeQuotedTextForCount: jest.fn((text) => text),
+ assertStoredMessageMutationAllowed: jest.fn(),
+ assertChatMutationAllowed: jest.fn(),
+ assertStoredMessageBranchAllowed: jest.fn(),
+ mergeUserSubmittedPaths: (...lists) => [...new Set(lists.flat().filter(Boolean))],
+ mergeUserSubmittedMessageFieldPaths: (...lists) => lists.flat().filter(Boolean),
+ isContentFilterError: jest.fn(() => false),
+ CHILD_THREAD_READ_ONLY_ERROR: 'Child thread is view-only.',
+ isSubagentThreadWriteBlocked: jest.fn().mockResolvedValue(false),
+ requireFeedbackEnabled: (req, res, next) => next(),
+ applyForcedRetention: jest.fn(),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({}));
+
+jest.mock('~/models', () => ({
+ stampForcedRetention: jest.fn(),
+ saveConvo: jest.fn(),
+ getConvo: jest.fn(),
+ getMessage: jest.fn(),
+ saveMessage: jest.fn(),
+ getMessages: jest.fn(),
+ getFiles: jest.fn().mockResolvedValue([]),
+ updateMessage: jest.fn(),
+ deleteMessages: jest.fn(),
+}));
+
+jest.mock('~/server/services/Artifacts/update', () => ({
+ findAllArtifacts: jest.fn(),
+ replaceArtifactContent: jest.fn(),
+}));
+
+jest.mock('~/server/middleware', () => ({
+ requireJwtAuth: (req, res, next) => next(),
+ validateMessageReq: (req, res, next) => next(),
+ configMiddleware: jest.fn((req, res, next) => next()),
+ sendValidationResponse: jest.fn(),
+ canReadActiveJobConversation: jest.fn().mockResolvedValue(false),
+ prepareMessageRequestValidation: jest.fn(),
+}));
+
+/**
+ * Each of these routes writes a message without going through the conversation, so under
+ * forced retention the chat holding it has to be re-stamped too. Whether the helper writes
+ * is decided by the retention mode and covered in
+ * `packages/api/src/conversations/retention.spec.ts`.
+ */
+describe('message writes that bypass conversation retention', () => {
+ let app;
+ const { configMiddleware } = require('~/server/middleware');
+ const { getMessage, saveMessage, stampForcedRetention } = require('~/models');
+ const { applyForcedRetention } = require('@librechat/api');
+ const {
+ findAllArtifacts,
+ replaceArtifactContent,
+ } = require('~/server/services/Artifacts/update');
+
+ const userId = 'user-1';
+ const conversationId = 'conversation-1';
+ const messageId = 'message-1';
+ const interfaceConfig = { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 };
+
+ beforeAll(() => {
+ const messagesRouter = require('../messages');
+
+ app = express();
+ app.use(express.json());
+ app.use((req, res, next) => {
+ req.user = { id: userId };
+ req.config = { interfaceConfig };
+ next();
+ });
+ app.use('/api/messages', messagesRouter);
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ configMiddleware.mockImplementation((req, res, next) => next());
+ });
+
+ describe('POST /artifact/:messageId', () => {
+ beforeEach(() => {
+ getMessage.mockResolvedValue({
+ messageId,
+ conversationId,
+ text: 'original artifact',
+ content: undefined,
+ });
+ findAllArtifacts.mockReturnValue([{ source: 'text', partIndex: 0 }]);
+ replaceArtifactContent.mockReturnValue('updated artifact');
+ saveMessage.mockResolvedValue({ messageId, conversationId, text: 'updated artifact' });
+ });
+
+ it('re-stamps the parent conversation of the edited message', async () => {
+ const response = await request(app)
+ .post(`/api/messages/artifact/${messageId}`)
+ .send({ index: 0, original: 'original artifact', updated: 'updated artifact' });
+
+ expect(response.status).toBe(200);
+ expect(saveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ userId, interfaceConfig }),
+ expect.objectContaining({ messageId, conversationId, user: userId }),
+ { context: 'POST /api/messages/artifact/:messageId' },
+ );
+ expect(applyForcedRetention).toHaveBeenCalledWith(
+ { stampForcedRetention },
+ {
+ ctx: expect.objectContaining({ userId, interfaceConfig }),
+ conversationId,
+ },
+ );
+ });
+
+ it('does not touch the conversation when the artifact edit fails to match', async () => {
+ replaceArtifactContent.mockReturnValue(null);
+
+ const response = await request(app)
+ .post(`/api/messages/artifact/${messageId}`)
+ .send({ index: 0, original: 'missing', updated: 'updated artifact' });
+
+ expect(response.status).toBe(400);
+ expect(saveMessage).not.toHaveBeenCalled();
+ expect(applyForcedRetention).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('PUT /:conversationId/:messageId/feedback', () => {
+ const { updateMessage } = require('~/models');
+
+ it('re-stamps the chat when feedback is the first write after the mode is enabled', async () => {
+ updateMessage.mockResolvedValue({
+ messageId,
+ conversationId,
+ endpoint: 'openAI',
+ feedback: { rating: 'thumbsDown', tag: 'inaccurate' },
+ });
+
+ const response = await request(app)
+ .put(`/api/messages/${conversationId}/${messageId}/feedback`)
+ .send({ feedback: { rating: 'thumbsDown', tag: 'inaccurate' } });
+
+ expect(response.status).toBe(200);
+ expect(applyForcedRetention).toHaveBeenCalledWith(
+ { stampForcedRetention },
+ {
+ ctx: expect.objectContaining({ userId, interfaceConfig }),
+ conversationId,
+ messageId,
+ },
+ );
+ });
+
+ it('re-stamps the stored conversation, not the one named in the route', async () => {
+ updateMessage.mockResolvedValue({
+ messageId,
+ conversationId: 'conversation-2',
+ endpoint: 'openAI',
+ feedback: { rating: 'thumbsDown', tag: 'inaccurate' },
+ });
+
+ const response = await request(app)
+ .put(`/api/messages/${conversationId}/${messageId}/feedback`)
+ .send({ feedback: { rating: 'thumbsDown', tag: 'inaccurate' } });
+
+ expect(response.status).toBe(200);
+ expect(applyForcedRetention).toHaveBeenCalledWith(
+ { stampForcedRetention },
+ expect.objectContaining({ conversationId: 'conversation-2', messageId }),
+ );
+ });
+ });
+
+ describe('POST /branch', () => {
+ const agentId = 'agent-1';
+ const sourceMessage = {
+ messageId,
+ conversationId,
+ parentMessageId: 'parent-1',
+ isCreatedByUser: false,
+ isTemporary: false,
+ content: [{ type: 'text', text: 'branched part', agentId }],
+ };
+
+ beforeEach(() => {
+ getMessage.mockResolvedValue(sourceMessage);
+ saveMessage.mockImplementation((_ctx, message) =>
+ Promise.resolve({ ...message, expiredAt: undefined }),
+ );
+ });
+
+ it('re-stamps the conversation the branch was created in', async () => {
+ const response = await request(app).post('/api/messages/branch').send({ messageId, agentId });
+
+ expect(response.status).toBe(201);
+ expect(applyForcedRetention).toHaveBeenCalledWith(
+ { stampForcedRetention },
+ {
+ ctx: expect.objectContaining({ userId, interfaceConfig }),
+ conversationId,
+ },
+ );
+ });
+
+ it('does not re-stamp when the branch message cannot be saved', async () => {
+ saveMessage.mockResolvedValue(null);
+
+ const response = await request(app).post('/api/messages/branch').send({ messageId, agentId });
+
+ expect(response.status).toBe(500);
+ expect(applyForcedRetention).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/api/server/routes/__tests__/messages-update.spec.js b/api/server/routes/__tests__/messages-update.spec.js
new file mode 100644
index 00000000000..a58e3cd4750
--- /dev/null
+++ b/api/server/routes/__tests__/messages-update.spec.js
@@ -0,0 +1,179 @@
+const express = require('express');
+const request = require('supertest');
+const { ContentTypes, RetentionMode } = require('librechat-data-provider');
+
+jest.mock('@librechat/agents', () => ({
+ sleep: jest.fn(),
+}));
+
+jest.mock('@librechat/api', () => ({
+ unescapeLaTeX: jest.fn((value) => value),
+ countTokens: jest.fn().mockResolvedValue(10),
+ createContentFilter: jest.fn(() => (_req, _res, next) => next()),
+ sendFeedbackScore: jest.fn().mockResolvedValue(undefined),
+ traceIdForMessage: jest.fn((messageId) => `trace-${messageId}`),
+ mergeQuotedTextForCount: jest.fn((text) => text),
+ assertStoredMessageMutationAllowed: jest.fn(),
+ assertChatMutationAllowed: jest.fn(),
+ assertStoredMessageBranchAllowed: jest.fn(),
+ mergeUserSubmittedPaths: (...lists) => [...new Set(lists.flat().filter(Boolean))],
+ mergeUserSubmittedMessageFieldPaths: (...lists) => lists.flat().filter(Boolean),
+ isContentFilterError: jest.fn(() => false),
+ CHILD_THREAD_READ_ONLY_ERROR: 'Child thread is view-only.',
+ isSubagentThreadWriteBlocked: jest.fn().mockResolvedValue(false),
+ requireFeedbackEnabled: (req, res, next) => next(),
+ applyForcedRetention: jest.fn(),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({}));
+
+jest.mock('~/models', () => ({
+ stampForcedRetention: jest.fn(),
+ saveConvo: jest.fn(),
+ getConvo: jest.fn(),
+ getMessage: jest.fn(),
+ saveMessage: jest.fn(),
+ getMessages: jest.fn(),
+ getFiles: jest.fn().mockResolvedValue([]),
+ updateMessage: jest.fn(),
+ deleteMessages: jest.fn(),
+}));
+
+jest.mock('~/server/services/Artifacts/update', () => ({
+ findAllArtifacts: jest.fn(),
+ replaceArtifactContent: jest.fn(),
+}));
+
+jest.mock('~/server/middleware', () => ({
+ requireJwtAuth: (req, res, next) => next(),
+ validateMessageReq: (req, res, next) => next(),
+ configMiddleware: jest.fn((req, res, next) => next()),
+ sendValidationResponse: jest.fn(),
+ canReadActiveJobConversation: jest.fn().mockResolvedValue(false),
+ prepareMessageRequestValidation: jest.fn(),
+}));
+
+/**
+ * The route's job is to hand the edited message to the retention helper. Whether it writes is
+ * decided by the retention mode (`packages/api/src/conversations/retention.spec.ts`), and which
+ * deadline it writes comes from the stored row (`stampForcedRetention` in
+ * `packages/data-schemas/src/methods/conversation.spec.ts`).
+ */
+describe('PUT /:conversationId/:messageId', () => {
+ let app;
+ const { configMiddleware } = require('~/server/middleware');
+ const { getMessages, stampForcedRetention, updateMessage } = require('~/models');
+ const { countTokens, applyForcedRetention } = require('@librechat/api');
+
+ const userId = 'user-1';
+ const conversationId = 'conversation-1';
+ const messageId = 'message-1';
+ const interfaceConfig = { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 };
+
+ const expectRestamp = (ctx) =>
+ expect(applyForcedRetention).toHaveBeenCalledWith(
+ { stampForcedRetention },
+ { ctx: expect.objectContaining(ctx), conversationId, messageId },
+ );
+
+ beforeAll(() => {
+ const messagesRouter = require('../messages');
+
+ app = express();
+ app.use(express.json());
+ app.use((req, res, next) => {
+ req.user = { id: userId };
+ req.config = { interfaceConfig };
+ next();
+ });
+ app.use('/api/messages', messagesRouter);
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ configMiddleware.mockImplementation((req, res, next) => next());
+ updateMessage.mockImplementation((authenticatedUserId, payload) =>
+ Promise.resolve({
+ messageId: payload.messageId,
+ conversationId,
+ text: payload.text,
+ content: payload.content,
+ tokenCount: payload.tokenCount,
+ }),
+ );
+ });
+
+ it('applies forced retention when editing message text', async () => {
+ getMessages.mockResolvedValue([{ conversationId, quotes: [], isCreatedByUser: true }]);
+
+ const response = await request(app)
+ .put(`/api/messages/${conversationId}/${messageId}`)
+ .send({ text: 'edited text', model: 'gpt-5' });
+
+ expect(response.status).toBe(200);
+ expect(countTokens).toHaveBeenCalledWith('edited text', 'gpt-5');
+ expect(updateMessage).toHaveBeenCalledWith(
+ userId,
+ expect.objectContaining({ messageId, text: 'edited text', tokenCount: 10 }),
+ );
+ expectRestamp({ userId, interfaceConfig });
+ });
+
+ it('applies forced retention when editing a text content part', async () => {
+ getMessages.mockResolvedValue([
+ {
+ conversationId,
+ content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'old text' }],
+ tokenCount: 8,
+ },
+ ]);
+
+ const response = await request(app)
+ .put(`/api/messages/${conversationId}/${messageId}`)
+ .send({ text: 'new text', index: 0, model: 'gpt-5' });
+
+ expect(response.status).toBe(200);
+ expect(updateMessage).toHaveBeenCalledWith(
+ userId,
+ expect.objectContaining({
+ messageId,
+ content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'new text' }],
+ }),
+ );
+ expectRestamp({ userId, interfaceConfig });
+ });
+
+ it('rejects a message that belongs to another conversation', async () => {
+ getMessages.mockResolvedValue([{ conversationId: 'conversation-2' }]);
+
+ const response = await request(app)
+ .put(`/api/messages/${conversationId}/${messageId}`)
+ .send({ text: 'edited text', model: 'gpt-5' });
+
+ expect(response.status).toBe(404);
+ expect(updateMessage).not.toHaveBeenCalled();
+ expect(applyForcedRetention).not.toHaveBeenCalled();
+ });
+
+ it('does not apply retention when the message update fails', async () => {
+ getMessages.mockResolvedValue([{ conversationId, quotes: [], isCreatedByUser: false }]);
+ updateMessage.mockRejectedValue(new Error('Message not found or user not authorized.'));
+
+ const response = await request(app)
+ .put(`/api/messages/${conversationId}/${messageId}`)
+ .send({ text: 'edited text' });
+
+ expect(response.status).toBe(500);
+ expect(applyForcedRetention).not.toHaveBeenCalled();
+ });
+});
diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js
index 4084649e405..060c463577a 100644
--- a/api/server/routes/__tests__/share.spec.js
+++ b/api/server/routes/__tests__/share.spec.js
@@ -128,27 +128,29 @@ jest.mock('@librechat/data-schemas', () => ({
SystemCapabilities: { ACCESS_ADMIN: 'access:admin' },
}));
-jest.mock('librechat-data-provider', () => ({
- PermissionTypes: {
- SHARED_LINKS: 'SHARED_LINKS',
- },
- Permissions: {
- CREATE: 'CREATE',
- SHARE_PUBLIC: 'SHARE_PUBLIC',
- },
- RetentionMode: {
- ALL: 'all',
- TEMPORARY: 'temporary',
- },
- FileSources: {
- local: 'local',
- s3: 's3',
- cloudfront: 'cloudfront',
- azure_blob: 'azure_blob',
- firebase: 'firebase',
- text: 'text',
- },
-}));
+jest.mock('librechat-data-provider', () => {
+ const RetentionMode = { ALL: 'all', TEMPORARY: 'temporary', EPHEMERAL: 'ephemeral' };
+ return {
+ PermissionTypes: {
+ SHARED_LINKS: 'SHARED_LINKS',
+ },
+ Permissions: {
+ CREATE: 'CREATE',
+ SHARE_PUBLIC: 'SHARE_PUBLIC',
+ },
+ RetentionMode,
+ isAllDataRetention: (mode) => mode === RetentionMode.ALL || mode === RetentionMode.EPHEMERAL,
+ isForcedTemporaryRetention: (mode) => mode === RetentionMode.EPHEMERAL,
+ FileSources: {
+ local: 'local',
+ s3: 's3',
+ cloudfront: 'cloudfront',
+ azure_blob: 'azure_blob',
+ firebase: 'firebase',
+ text: 'text',
+ },
+ };
+});
jest.mock('mongoose', () => ({
models: {
diff --git a/api/server/routes/agents/__tests__/streamTenant.spec.js b/api/server/routes/agents/__tests__/streamTenant.spec.js
index d3e3f28afeb..1003e99ea78 100644
--- a/api/server/routes/agents/__tests__/streamTenant.spec.js
+++ b/api/server/routes/agents/__tests__/streamTenant.spec.js
@@ -508,6 +508,25 @@ describe('SSE stream tenant isolation', () => {
expect(res.body.active).toBe(true);
});
+ it.each([true, false])(
+ 'reports the run temporary state %s recorded at admission',
+ async (isTemporary) => {
+ mockUserId = 'user-123';
+ mockTenantId = 'tenant-a';
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'user-123', tenantId: 'tenant-a', isTemporary },
+ status: 'running',
+ createdAt: Date.now(),
+ });
+ mockGenerationJobManager.getResumeState.mockResolvedValue(null);
+
+ const res = await request(app).get('/agents/chat/status/conv-123');
+
+ expect(res.status).toBe(200);
+ expect(res.body.isTemporary).toBe(isTemporary);
+ },
+ );
+
it('preserves the immutable v2 marker on an active status response', async () => {
mockGenerationJobManager.getJob.mockResolvedValue({
metadata: { userId: 'user-123', generationProtocolVersion: 2 },
diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js
index 0a4cb3290d5..2442868e5d1 100644
--- a/api/server/routes/agents/index.js
+++ b/api/server/routes/agents/index.js
@@ -550,6 +550,7 @@ router.get('/chat/status/:conversationId', async (req, res) => {
aggregatedContent: resumeState?.aggregatedContent ?? [],
createdAt: job.createdAt,
elapsedMs: getGenerationElapsedMs(job),
+ isTemporary: job.metadata?.isTemporary === true,
resumeState,
// Surface the live pending approval so a client rebuilding from /chat/status
// (reload / cross-replica) has the action id + payload to render and submit
diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js
index 77e68cd63bb..f901e48dbf4 100644
--- a/api/server/routes/convos.js
+++ b/api/server/routes/convos.js
@@ -851,6 +851,7 @@ router.post('/fork', forkIpLimiter, forkUserLimiter, configMiddleware, async (re
records: true,
splitAtTarget,
option,
+ interfaceConfig: req.config?.interfaceConfig,
filters: req.config?.filters,
...(req.config?.messageFilter?.pii == null
? {}
@@ -884,6 +885,7 @@ router.post(
userId: req.user.id,
conversationId,
title,
+ interfaceConfig: req.config?.interfaceConfig,
filters: req.config?.filters,
...(req.config?.messageFilter?.pii == null
? {}
diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js
index 6ac391c18d4..b07e37c01d1 100644
--- a/api/server/routes/messages.js
+++ b/api/server/routes/messages.js
@@ -27,6 +27,7 @@ const {
mergeUserSubmittedMessageFieldPaths,
isContentFilterError,
withoutTraceRefs,
+ applyForcedRetention,
} = require('@librechat/api');
const subagentThreadTaskStore = require('~/server/services/Endpoints/agents/subagentThreadStore');
const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update');
@@ -40,6 +41,8 @@ const {
} = require('~/server/middleware');
const db = require('~/models');
+const retentionStore = { stampForcedRetention: db.stampForcedRetention };
+
const router = express.Router();
const filterStoredMessageContent = createContentFilter({
messageCount: 1,
@@ -374,6 +377,16 @@ router.post('/branch', configMiddleware, async (req, res) => {
return res.status(500).json({ error: 'Failed to save branch message' });
}
+ await applyForcedRetention(retentionStore, {
+ ctx: {
+ userId,
+ isTemporary: sourceMessage.isTemporary,
+ expiredAt: savedMessage.expiredAt ?? sourceMessage.expiredAt,
+ interfaceConfig: req?.config?.interfaceConfig,
+ },
+ conversationId: sourceMessage.conversationId,
+ });
+
res.status(201).json(toClientMessage(savedMessage));
} catch (error) {
if (isContentFilterError(error)) {
@@ -450,13 +463,15 @@ router.post('/artifact/:messageId', configMiddleware, async (req, res) => {
: { text: updatedText };
assertStoredMessageMutationAllowed(req.config?.filters, filteredArtifact);
+ const reqCtx = {
+ userId: req?.user?.id,
+ isTemporary: message.isTemporary,
+ expiredAt: message.expiredAt,
+ interfaceConfig: req?.config?.interfaceConfig,
+ };
+ const context = 'POST /api/messages/artifact/:messageId';
const savedMessage = await db.saveMessage(
- {
- userId: req?.user?.id,
- isTemporary: message.isTemporary,
- expiredAt: message.expiredAt,
- interfaceConfig: req?.config?.interfaceConfig,
- },
+ reqCtx,
{
messageId,
conversationId: message.conversationId,
@@ -470,8 +485,12 @@ router.post('/artifact/:messageId', configMiddleware, async (req, res) => {
),
user: req.user.id,
},
- { context: 'POST /api/messages/artifact/:messageId' },
+ { context },
);
+ await applyForcedRetention(retentionStore, {
+ ctx: reqCtx,
+ conversationId: message.conversationId,
+ });
res.status(200).json({
conversationId: savedMessage.conversationId,
@@ -599,6 +618,7 @@ router.put('/:conversationId/:messageId', messageMutationMiddleware, async (req,
if (index !== undefined && (typeof index !== 'number' || index < 0)) {
return res.status(400).json({ error: 'Invalid index' });
}
+ const reqCtx = { userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig };
if (index === undefined) {
assertStoredMessageMutationAllowed(req.config?.filters, { text });
@@ -623,6 +643,11 @@ router.put('/:conversationId/:messageId', messageMutationMiddleware, async (req,
tokenCount,
userSubmittedPaths: mergeUserSubmittedPaths(message.userSubmittedPaths, '/text'),
});
+ await applyForcedRetention(retentionStore, {
+ ctx: reqCtx,
+ conversationId,
+ messageId,
+ });
return res.status(200).json(result);
}
@@ -680,6 +705,11 @@ router.put('/:conversationId/:messageId', messageMutationMiddleware, async (req,
`/content/${index}/${currentPartType}`,
),
});
+ await applyForcedRetention(retentionStore, {
+ ctx: reqCtx,
+ conversationId,
+ messageId,
+ });
return res.status(200).json(result);
} catch (error) {
if (isContentFilterError(error)) {
@@ -738,6 +768,12 @@ router.put(
}).catch((err) => logger.error('[langfuse] feedback score failed:', err));
}
+ await applyForcedRetention(retentionStore, {
+ ctx: { userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig },
+ conversationId: updatedMessage.conversationId,
+ messageId,
+ });
+
res.json({
messageId,
conversationId,
diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js
index 1a5bac88fb2..82dcfae4d65 100644
--- a/api/server/services/AuthService.spec.js
+++ b/api/server/services/AuthService.spec.js
@@ -1,3 +1,4 @@
+/** Installed packages need real module IDs when Jest reuses a resolver across suites. */
jest.mock(
'@librechat/data-schemas',
() => ({
@@ -7,7 +8,7 @@ jest.mock(
DEFAULT_SESSION_EXPIRY: 900000,
DEFAULT_REFRESH_TOKEN_EXPIRY: 604800000,
}),
- { virtual: true },
+ { virtual: false },
);
jest.mock(
'librechat-data-provider',
@@ -16,7 +17,7 @@ jest.mock(
SystemRoles: { USER: 'USER', ADMIN: 'ADMIN' },
errorsToString: jest.fn(),
}),
- { virtual: true },
+ { virtual: false },
);
jest.mock(
'@librechat/api',
@@ -95,7 +96,7 @@ jest.mock(
CLOUDFRONT_SCOPE_COOKIE: 'LibreChat-CloudFront-Scope',
};
},
- { virtual: true },
+ { virtual: false },
);
jest.mock('~/models', () => ({
findUser: jest.fn(),
diff --git a/api/server/services/Endpoints/assistants/title.test.js b/api/server/services/Endpoints/assistants/title.test.js
index f22d87eec3e..93bc3e765ad 100644
--- a/api/server/services/Endpoints/assistants/title.test.js
+++ b/api/server/services/Endpoints/assistants/title.test.js
@@ -32,6 +32,17 @@ describe('assistants addTitle content policy', () => {
jest.clearAllMocks();
});
+ it('skips the title provider, cache, and save for a normalized temporary request', async () => {
+ await addTitle(
+ { user: { id: 'user-1' }, body: { isTemporary: true } },
+ { text: 'temporary input', responseText: 'response', conversationId: 'conversation-1' },
+ );
+
+ expect(mockInitializeClient).not.toHaveBeenCalled();
+ expect(mockCache.set).not.toHaveBeenCalled();
+ expect(mockSaveConvo).not.toHaveBeenCalled();
+ });
+
it('replaces a blocked generated title before caching or saving it', async () => {
const create = jest.fn().mockResolvedValue({
choices: [{ message: { content: 'BLOCKED-GENERATED-TITLE' } }],
diff --git a/api/server/services/Threads/manage.retention.spec.js b/api/server/services/Threads/manage.retention.spec.js
index c3469ecb4b4..8c59dd5d037 100644
--- a/api/server/services/Threads/manage.retention.spec.js
+++ b/api/server/services/Threads/manage.retention.spec.js
@@ -177,4 +177,42 @@ describe('Assistants message retention', () => {
history.mockRestore();
}
});
+
+ it('forces the whole turn temporary under ephemeral retention', async () => {
+ const user = new mongoose.Types.ObjectId().toString();
+ const conversationId = v4();
+ const req = {
+ user: { id: user },
+ body: { conversationId, isTemporary: false },
+ resolvedConversation: null,
+ config: {
+ interfaceConfig: { retentionMode: 'ephemeral', temporaryChatRetention: 1 },
+ },
+ };
+ const params = {
+ user,
+ conversationId,
+ endpoint: 'assistants',
+ assistant_id: 'asst_test',
+ thread_id: 'thread_test',
+ text: 'hello',
+ };
+ const startedAt = Date.now();
+ const userMessage = await saveUserMessage(req, { ...params, messageId: v4() });
+ await saveAssistantMessage(req, {
+ ...params,
+ messageId: v4(),
+ parentMessageId: userMessage.messageId,
+ content: [],
+ });
+
+ const rows = await Message.find({ user, conversationId }).lean();
+ const convo = await Conversation.findOne({ user, conversationId }).lean();
+ expect(rows).toHaveLength(2);
+ for (const row of [...rows, convo]) {
+ expect(row.isTemporary).toBe(true);
+ expect(row.expiredAt.getTime()).toBeGreaterThanOrEqual(startedAt + 3600000);
+ expect(row.expiredAt.getTime()).toBeLessThan(startedAt + 3600000 + 5000);
+ }
+ });
});
diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js
index b777eed80da..861e612e225 100644
--- a/api/server/utils/import/fork.js
+++ b/api/server/utils/import/fork.js
@@ -1,5 +1,10 @@
const { v4: uuidv4 } = require('uuid');
-const { cloneLineage, withoutTraceRefs, getAllMessagesUpToParent } = require('@librechat/api');
+const {
+ cloneLineage,
+ withoutTraceRefs,
+ isTemporaryRecord,
+ getAllMessagesUpToParent,
+} = require('@librechat/api');
const { logger, tenantStorage } = require('@librechat/data-schemas');
const { EModelEndpoint, Constants, ForkOptions } = require('librechat-data-provider');
const { getConvo, getMessages, getSharedMessages } = require('~/models');
@@ -51,6 +56,7 @@ function cloneMessagesWithTimestamps(
* @param {boolean} [params.records=false] - Optional flag for returning actual database records or resulting conversation and messages.
* @param {boolean} [params.splitAtTarget=false] - Optional flag for splitting the messages at the target message level.
* @param {string} [params.latestMessageId] - latestMessageId - Required if splitAtTarget is true.
+ * @param {object} [params.interfaceConfig] - Runtime interface config used to apply retention to cloned records.
* @param {object} [params.filters] - Source-aware content filters applied before cloned records are persisted.
* @param {object} [params.legacyPii] - Legacy messageFilter.pii applied before cloned records are persisted.
* @param {(userId: string, interfaceConfig?: object, filters?: object, legacyPii?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance.
@@ -68,6 +74,7 @@ async function forkConversation({
filters,
legacyPii,
builderFactory = createImportBatchBuilder,
+ interfaceConfig,
}) {
try {
const originalConvo = await getConvo(requestUserId, originalConvoId);
@@ -86,8 +93,9 @@ async function forkConversation({
const importBatchBuilder =
legacyPii == null
- ? builderFactory(requestUserId, undefined, filters)
- : builderFactory(requestUserId, undefined, filters, legacyPii);
+ ? builderFactory(requestUserId, interfaceConfig, filters)
+ : builderFactory(requestUserId, interfaceConfig, filters, legacyPii);
+ importBatchBuilder.sourceIsTemporary = isTemporaryRecord(originalConvo);
importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI);
let messagesToClone = [];
@@ -479,6 +487,7 @@ async function forkSharedConversation({
* @param {string} params.userId - The ID of the user duplicating the conversation.
* @param {string} params.conversationId - The ID of the conversation to duplicate.
* @param {string} [params.title] - Optional title override for the duplicate.
+ * @param {object} [params.interfaceConfig] - Runtime interface config used to apply retention to cloned records.
* @param {object} [params.filters] - Source-aware content filters applied before cloned records are persisted.
* @param {object} [params.legacyPii] - Legacy messageFilter.pii applied before cloned records are persisted.
* @param {(userId: string, interfaceConfig?: object, filters?: object, legacyPii?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance.
@@ -488,6 +497,7 @@ async function duplicateConversation({
userId,
conversationId,
title,
+ interfaceConfig,
filters,
legacyPii,
builderFactory = createImportBatchBuilder,
@@ -509,8 +519,9 @@ async function duplicateConversation({
const importBatchBuilder =
legacyPii == null
- ? builderFactory(userId, undefined, filters)
- : builderFactory(userId, undefined, filters, legacyPii);
+ ? builderFactory(userId, interfaceConfig, filters)
+ : builderFactory(userId, interfaceConfig, filters, legacyPii);
+ importBatchBuilder.sourceIsTemporary = isTemporaryRecord(originalConvo);
importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI);
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
diff --git a/api/server/utils/import/fork.spec.js b/api/server/utils/import/fork.spec.js
index d53bb8775c1..2273ef2fc5c 100644
--- a/api/server/utils/import/fork.spec.js
+++ b/api/server/utils/import/fork.spec.js
@@ -1,4 +1,4 @@
-const { Constants, ForkOptions } = require('librechat-data-provider');
+const { Constants, ForkOptions, RetentionMode } = require('librechat-data-provider');
const mockLogger = {
debug: jest.fn(),
@@ -118,6 +118,28 @@ describe('forkConversation', () => {
bulkSaveMessages.mockResolvedValue(null);
});
+ test('applies ephemeral retention to forked conversation and messages', async () => {
+ await forkConversation({
+ originalConvoId: 'abc123',
+ targetMessageId: '3',
+ requestUserId: 'user1',
+ option: ForkOptions.DIRECT_PATH,
+ interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 },
+ });
+
+ expect(bulkSaveConvos).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }),
+ ]),
+ );
+ expect(bulkSaveMessages).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }),
+ ]),
+ true,
+ );
+ });
+
test('should fork conversation without branches', async () => {
const result = await forkConversation({
originalConvoId: 'abc123',
@@ -466,6 +488,75 @@ describe('duplicateConversation', () => {
bulkIncrementTagCounts.mockResolvedValue(null);
});
+ test('applies ephemeral retention to duplicated conversation and messages', async () => {
+ await duplicateConversation({
+ userId: 'user1',
+ conversationId: 'abc123',
+ interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 },
+ });
+
+ expect(bulkSaveConvos).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }),
+ ]),
+ );
+ expect(bulkSaveMessages).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }),
+ ]),
+ true,
+ );
+ });
+
+ test('keeps a duplicate of a temporary chat temporary under all-data retention', async () => {
+ getConvo.mockResolvedValue({ ...mockConversation, isTemporary: true });
+
+ const result = await duplicateConversation({
+ userId: 'user1',
+ conversationId: 'abc123',
+ interfaceConfig: { retentionMode: RetentionMode.ALL, generalChatRetention: 2160 },
+ });
+
+ expect(result.conversation.isTemporary).toBe(true);
+ });
+
+ test('keeps a duplicate of a legacy temporary chat temporary under all-data retention', async () => {
+ getConvo.mockResolvedValue({ ...mockConversation, expiredAt: new Date(Date.now() + 60_000) });
+
+ await duplicateConversation({
+ userId: 'user1',
+ conversationId: 'abc123',
+ interfaceConfig: { retentionMode: RetentionMode.ALL, generalChatRetention: 2160 },
+ });
+
+ expect(bulkSaveConvos.mock.calls[0][0][0].isTemporary).toBe(true);
+ });
+
+ test('leaves a duplicate of an ordinary chat visible under all-data retention', async () => {
+ getConvo.mockResolvedValue({ ...mockConversation, isTemporary: false });
+
+ const result = await duplicateConversation({
+ userId: 'user1',
+ conversationId: 'abc123',
+ interfaceConfig: { retentionMode: RetentionMode.ALL, generalChatRetention: 2160 },
+ });
+
+ expect(result.conversation.isTemporary).toBe(false);
+ });
+
+ test('neither counts nor stores tags on forced-temporary duplicates', async () => {
+ getConvo.mockResolvedValue({ ...mockConversation, tags: ['important', 'work'] });
+
+ await duplicateConversation({
+ userId: 'user1',
+ conversationId: 'abc123',
+ interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 },
+ });
+
+ expect(bulkIncrementTagCounts.mock.calls.flatMap(([, tags]) => tags)).toEqual([]);
+ expect(bulkSaveConvos.mock.calls[0][0].map((convo) => convo.tags)).toEqual([[]]);
+ });
+
test('should duplicate conversation and increment tag counts', async () => {
const mockConvoWithTags = {
...mockConversation,
diff --git a/api/server/utils/import/importBatchBuilder.js b/api/server/utils/import/importBatchBuilder.js
index 50e05a0a3d3..f7421fce57c 100644
--- a/api/server/utils/import/importBatchBuilder.js
+++ b/api/server/utils/import/importBatchBuilder.js
@@ -5,6 +5,8 @@ const {
assertConversationImportContentAllowed,
reportLocatorTraversalFailure,
executeConversationImportWrites,
+ resolveImportRetentionFields,
+ resolveImportTagCounts,
} = require('@librechat/api');
const {
getTenantId,
@@ -12,12 +14,7 @@ const {
createFallbackRetentionDate,
createChatExpirationDate,
} = require('@librechat/data-schemas');
-const {
- EModelEndpoint,
- Constants,
- RetentionMode,
- openAISettings,
-} = require('librechat-data-provider');
+const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider');
const {
bulkIncrementTagCounts,
bulkSaveConvos,
@@ -81,26 +78,17 @@ class ImportBatchBuilder {
this.conversations = [];
this.messages = [];
this.retentionFields = undefined;
+ /** Set by a fork or duplicate so the copy keeps its source's temporary classification. */
+ this.sourceIsTemporary = undefined;
}
getRetentionFields() {
- if (this.retentionFields !== undefined) {
- return this.retentionFields;
- }
-
- if (this.interfaceConfig?.retentionMode !== RetentionMode.ALL) {
- this.retentionFields = {};
- return this.retentionFields;
- }
-
- try {
- this.retentionFields = {
- isTemporary: false,
- expiredAt: createChatExpirationDate(this.interfaceConfig),
- };
- } catch (error) {
- logger.error('[ImportBatchBuilder] Error creating import expiration date:', error);
- this.retentionFields = { isTemporary: false, expiredAt: createFallbackRetentionDate() };
+ if (this.retentionFields === undefined) {
+ this.retentionFields = resolveImportRetentionFields(
+ this.interfaceConfig,
+ { createChatExpirationDate, createFallbackRetentionDate, logger },
+ { sourceIsTemporary: this.sourceIsTemporary },
+ );
}
return this.retentionFields;
}
@@ -174,6 +162,9 @@ class ImportBatchBuilder {
endpoint: this.endpoint,
model: originalConvo.model ?? fallbackModel,
...this.getRetentionFields(),
+ ...(originalConvo.tags != null && {
+ tags: resolveImportTagCounts(this.getRetentionFields(), originalConvo.tags),
+ }),
};
convo._id && delete convo._id;
delete convo.subagentThread;
@@ -221,7 +212,10 @@ class ImportBatchBuilder {
conversationIds,
...(tenantId == null ? {} : { tenantId }),
};
- const tags = this.conversations.flatMap((convo) => convo.tags);
+ const tags = resolveImportTagCounts(
+ this.getRetentionFields(),
+ this.conversations.flatMap((convo) => convo.tags),
+ );
try {
await executeConversationImportWrites({
diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js
index b9d945af94c..b4b2062ed82 100644
--- a/api/server/utils/import/importers.spec.js
+++ b/api/server/utils/import/importers.spec.js
@@ -1355,6 +1355,23 @@ describe('importLibreChatConvo', () => {
expect(message.expiredAt.getTime()).toBeLessThan(now + hours * 3600000 + 1000);
},
);
+
+ it('marks imported conversations and messages temporary under ephemeral retention', () => {
+ const requestUserId = 'user-123';
+ const builder = new ImportBatchBuilder(requestUserId, {
+ retentionMode: RetentionMode.EPHEMERAL,
+ temporaryChatRetention: 24,
+ });
+ builder.startConversation(EModelEndpoint.openAI);
+ const message = builder.addUserMessage('Ephemeral import');
+ const result = builder.finishConversation('Imported ephemeral chat');
+
+ expect(message.isTemporary).toBe(true);
+ expect(message.expiredAt).toBeInstanceOf(Date);
+ expect(result.conversation.isTemporary).toBe(true);
+ expect(result.conversation.expiredAt).toBeInstanceOf(Date);
+ expect(result.conversation.expiredAt).toBe(message.expiredAt);
+ });
});
});
diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx
index fd50568afaf..d30496d8eff 100644
--- a/client/src/components/Chat/Header.tsx
+++ b/client/src/components/Chat/Header.tsx
@@ -6,6 +6,7 @@ import {
Constants,
PermissionTypes,
Permissions,
+ isForcedTemporaryRetention,
} from 'librechat-data-provider';
import { OpenSidebar, PresetsMenu, NewChat, HeaderMenu } from './Menus';
import { TemporaryChat, TemporaryChatIndicator } from './TemporaryChat';
@@ -66,6 +67,10 @@ function Header({
permissionType: PermissionTypes.TEMPORARY_CHAT,
permission: Permissions.USE,
});
+ /** An administrator-enforced mode is not a role grant, so it is overlaid here rather than
+ * written into the role's stored permissions; the control is read-only either way. */
+ const showTemporaryChat =
+ hasAccessToTemporaryChat === true || isForcedTemporaryRetention(interfaceConfig.retentionMode);
/** Child threads are view-only records of their parent's run and have no trace of their own. */
const trace = useTraceControl({
@@ -112,7 +117,7 @@ function Header({
- {hasAccessToTemporaryChat === true && }
+ {showTemporaryChat && }
{!isNewChat && }
{!isNewChat && parentConversationId == null && (
{trace.show && }
- {hasAccessToTemporaryChat === true && }
+ {showTemporaryChat && }
diff --git a/client/src/components/Chat/Menus/HeaderMenu.tsx b/client/src/components/Chat/Menus/HeaderMenu.tsx
index fd8961c2129..ca6229fd9ca 100644
--- a/client/src/components/Chat/Menus/HeaderMenu.tsx
+++ b/client/src/components/Chat/Menus/HeaderMenu.tsx
@@ -57,7 +57,8 @@ export default function HeaderMenu({
const showBookmarks = hasAccessToBookmarks === true && bookmarks.show;
const showCompare = hasAccessToMultiConvo === true && multiConvo.show;
- const showTemporary = hasAccessToTemporaryChat === true && temporary.show;
+ const showTemporary =
+ (hasAccessToTemporaryChat === true || temporary.isEnforced) && temporary.show;
const items: t.MenuItemProps[] = [];
@@ -107,8 +108,11 @@ export default function HeaderMenu({
if (showTemporary) {
pushGroup({
id: 'header-temporary',
- label: localize('com_ui_temporary'),
+ label: temporary.isEnforced
+ ? localize('com_ui_temporary_enforced')
+ : localize('com_ui_temporary'),
ariaChecked: temporary.isTemporary,
+ disabled: temporary.isEnforced,
className: temporary.isTemporary ? 'bg-surface-active' : undefined,
icon: temporary.isTemporary ? (
diff --git a/client/src/components/Chat/Menus/__tests__/BookmarkMenu.spec.tsx b/client/src/components/Chat/Menus/__tests__/BookmarkMenu.spec.tsx
new file mode 100644
index 00000000000..af92a51c903
--- /dev/null
+++ b/client/src/components/Chat/Menus/__tests__/BookmarkMenu.spec.tsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import { RecoilRoot } from 'recoil';
+import { render, screen } from '@testing-library/react';
+import { RetentionMode } from 'librechat-data-provider';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { TConversation } from 'librechat-data-provider';
+import BookmarkMenu from '../BookmarkMenu';
+import store from '~/store';
+
+const mockUseGetStartupConfig = jest.fn();
+
+jest.mock('~/data-provider', () => ({
+ useGetStartupConfig: (...args: unknown[]) => mockUseGetStartupConfig(...args),
+ useConversationTagsQuery: () => ({ data: [] }),
+ useTagConversationMutation: () => ({ mutate: jest.fn(), isLoading: false }),
+}));
+
+jest.mock('~/hooks', () => ({
+ useLocalize: () => (key: string) => key,
+ useBookmarkSuccess: () => jest.fn(),
+}));
+
+jest.mock('@librechat/client', () => {
+ const ReactActual = jest.requireActual('react');
+ return {
+ DropdownPopup: ({ trigger }: { trigger: React.ReactNode }) =>
+ ReactActual.createElement('div', null, trigger),
+ TooltipAnchor: ({ render }: { render: React.ReactNode }) => render,
+ Spinner: () => null,
+ useToastContext: () => ({ showToast: jest.fn() }),
+ };
+});
+
+jest.mock('@ariakit/react', () => {
+ const ReactActual = jest.requireActual('react');
+ return {
+ MenuButton: (props: React.ButtonHTMLAttributes) =>
+ ReactActual.createElement('button', props, props.children),
+ };
+});
+
+jest.mock('~/components/Bookmarks', () => ({
+ BookmarkEditDialog: () => null,
+}));
+
+const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+function renderMenu({
+ conversation,
+ retentionMode,
+}: {
+ conversation: Partial;
+ retentionMode?: RetentionMode;
+}) {
+ mockUseGetStartupConfig.mockReturnValue({
+ data: retentionMode ? { interface: { retentionMode } } : { interface: {} },
+ });
+
+ return render(
+
+ {
+ set(store.conversationByIndex(0), conversation as TConversation);
+ }}
+ >
+
+
+ ,
+ );
+}
+
+describe('BookmarkMenu', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('shows bookmark controls for a permanent conversation', () => {
+ renderMenu({
+ conversation: { conversationId: 'convo-1', isTemporary: false },
+ retentionMode: RetentionMode.TEMPORARY,
+ });
+
+ expect(screen.getByTestId('bookmark-menu')).toBeInTheDocument();
+ });
+
+ it('hides bookmark controls for a temporary conversation', () => {
+ renderMenu({
+ conversation: { conversationId: 'convo-1', isTemporary: true },
+ retentionMode: RetentionMode.TEMPORARY,
+ });
+
+ expect(screen.queryByTestId('bookmark-menu')).not.toBeInTheDocument();
+ });
+
+ it('hides bookmark controls when ephemeral retention is forced on a permanent conversation', () => {
+ renderMenu({
+ conversation: { conversationId: 'convo-1', isTemporary: false },
+ retentionMode: RetentionMode.EPHEMERAL,
+ });
+
+ expect(screen.queryByTestId('bookmark-menu')).not.toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/Chat/TemporaryChat.tsx b/client/src/components/Chat/TemporaryChat.tsx
index f5e5e2f90b8..0ae2ae038d5 100644
--- a/client/src/components/Chat/TemporaryChat.tsx
+++ b/client/src/components/Chat/TemporaryChat.tsx
@@ -7,7 +7,7 @@ import { cn } from '~/utils';
export function TemporaryChat() {
const localize = useLocalize();
- const { show, isTemporary, toggle } = useTemporaryChat();
+ const { show, isTemporary, isEnforced, toggle } = useTemporaryChat();
const tooltipDescription = useShortcutHint('toggleTemporaryChat', localize('com_ui_temporary'));
const ariaKey = useShortcutAriaKey('toggleTemporaryChat');
@@ -15,21 +15,25 @@ export function TemporaryChat() {
return null;
}
+ const label = isEnforced ? localize('com_ui_temporary_enforced') : localize('com_ui_temporary');
+
return (
diff --git a/client/src/components/Chat/__tests__/Header.stacking.spec.tsx b/client/src/components/Chat/__tests__/Header.stacking.spec.tsx
index 712f43ca7ad..dbcf6d63f7d 100644
--- a/client/src/components/Chat/__tests__/Header.stacking.spec.tsx
+++ b/client/src/components/Chat/__tests__/Header.stacking.spec.tsx
@@ -18,6 +18,8 @@ jest.mock('librechat-data-provider', () => ({
EModelEndpoint: { agents: 'agents' },
PermissionTypes: { BOOKMARKS: 'bookmarks', MULTI_CONVO: 'multi_convo', TEMPORARY_CHAT: 'temp' },
Permissions: { USE: 'use' },
+ isForcedTemporaryRetention:
+ jest.requireActual('librechat-data-provider').isForcedTemporaryRetention,
}));
jest.mock('~/data-provider', () => ({ useGetStartupConfig: () => ({ data: undefined }) }));
jest.mock('~/hooks', () => ({ useHasAccess: () => false }));
diff --git a/client/src/components/Chat/__tests__/TemporaryChat.spec.tsx b/client/src/components/Chat/__tests__/TemporaryChat.spec.tsx
index ff8a5ad0bac..7b7e50d2a0f 100644
--- a/client/src/components/Chat/__tests__/TemporaryChat.spec.tsx
+++ b/client/src/components/Chat/__tests__/TemporaryChat.spec.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
+import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import type { TConversation } from 'librechat-data-provider';
@@ -20,6 +21,16 @@ jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => (key === 'com_ui_temporary' ? 'Temporary Chat' : key),
}));
+let mockRetentionMode: string | undefined;
+
+jest.mock('~/data-provider', () => ({
+ useGetStartupConfig: () => ({ data: { interface: { retentionMode: mockRetentionMode } } }),
+}));
+
+beforeEach(() => {
+ mockRetentionMode = undefined;
+});
+
function renderChat(
ui: React.ReactElement,
{ isTemporary, conversation }: { isTemporary: boolean; conversation?: Partial },
@@ -55,6 +66,26 @@ describe('TemporaryChat', () => {
);
});
+ it('locks the toggle on when the administrator enforces temporary chats', () => {
+ mockRetentionMode = 'ephemeral';
+ renderChat(, { isTemporary: false });
+
+ const toggle = screen.getByRole('button', { name: 'com_ui_temporary_enforced' });
+ expect(toggle).toHaveAttribute('aria-pressed', 'true');
+ expect(toggle).toHaveAttribute('aria-disabled', 'true');
+ expect(toggle).not.toHaveAttribute('aria-keyshortcuts');
+ });
+
+ it('keeps the toggle on after a click under enforced temporary chats', async () => {
+ mockRetentionMode = 'ephemeral';
+ renderChat(, { isTemporary: false });
+
+ const toggle = screen.getByRole('button', { name: 'com_ui_temporary_enforced' });
+ await userEvent.click(toggle);
+
+ expect(toggle).toHaveAttribute('aria-pressed', 'true');
+ });
+
it('retires the toggle once the conversation has started', () => {
renderChat(, {
isTemporary: true,
diff --git a/client/src/data-provider/SSE/queries.ts b/client/src/data-provider/SSE/queries.ts
index cf0d8b8889f..eca82371d8a 100644
--- a/client/src/data-provider/SSE/queries.ts
+++ b/client/src/data-provider/SSE/queries.ts
@@ -21,6 +21,9 @@ export interface StreamStatusResponse {
* can rebuild a clock-local elapsed baseline free of cross-machine skew. */
elapsedMs?: number;
resumeState?: Agents.ResumeState;
+ /** The run's temporary state as the server recorded it at admission, so a rebuilt
+ * submission keeps a hidden chat out of history and titling after a reload. */
+ isTemporary?: boolean;
/** Live pending approval when `status === 'requires_action'`; mirrors
* `resumeState.pendingAction`, surfaced top-level for the resume-on-load path. */
pendingAction?: Agents.PendingAction;
diff --git a/client/src/data-provider/__tests__/duplicateTagCounts.test.tsx b/client/src/data-provider/__tests__/duplicateTagCounts.test.tsx
new file mode 100644
index 00000000000..db0802a2d62
--- /dev/null
+++ b/client/src/data-provider/__tests__/duplicateTagCounts.test.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { act, renderHook } from '@testing-library/react';
+import { dataService, QueryKeys } from 'librechat-data-provider';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { TConversation, TConversationTag } from 'librechat-data-provider';
+import type { ReactNode } from 'react';
+import { useDuplicateConversationMutation } from '../mutations';
+
+jest.mock('librechat-data-provider', () => {
+ const actual = jest.requireActual('librechat-data-provider');
+ return {
+ ...actual,
+ dataService: {
+ ...actual.dataService,
+ duplicateConversation: jest.fn(),
+ },
+ };
+});
+
+const duplicateConversation = dataService.duplicateConversation as jest.MockedFunction<
+ typeof dataService.duplicateConversation
+>;
+
+const createWrapper = (queryClient: QueryClient) =>
+ function Wrapper({ children }: { children: ReactNode }) {
+ return {children};
+ };
+
+const tagCount = (queryClient: QueryClient) =>
+ queryClient.getQueryData([QueryKeys.conversationTags])?.[0]?.count;
+
+async function duplicateWith(conversation: Partial): Promise {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ queryClient.setQueryData(
+ [QueryKeys.conversationTags],
+ [{ tag: 'work', count: 1 } as TConversationTag],
+ );
+ duplicateConversation.mockResolvedValue({
+ conversation: { conversationId: 'copy-1', tags: ['work'], ...conversation } as TConversation,
+ messages: [],
+ });
+
+ const { result } = renderHook(() => useDuplicateConversationMutation(), {
+ wrapper: createWrapper(queryClient),
+ });
+ await act(async () => {
+ await result.current.mutateAsync({ conversationId: 'source-1' });
+ });
+ return queryClient;
+}
+
+describe('duplicate tag counts', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('counts the tags of a copy that stays in history', async () => {
+ const queryClient = await duplicateWith({ isTemporary: false });
+
+ expect(tagCount(queryClient)).toBe(2);
+ });
+
+ it('adds no count for a copy that retention keeps hidden', async () => {
+ const queryClient = await duplicateWith({ isTemporary: true });
+
+ expect(tagCount(queryClient)).toBe(1);
+ });
+});
diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts
index 665f8bbee6d..dc25b0b431c 100644
--- a/client/src/data-provider/mutations.ts
+++ b/client/src/data-provider/mutations.ts
@@ -24,6 +24,7 @@ import {
import useUpdateTagsInConvo from '~/hooks/Conversations/useUpdateTagsInConvo';
import { chatFilterTagsAtom } from '~/components/Conversations/chatFilters';
import { updateConversationTag } from '~/utils/conversationTags';
+import { isTemporaryConversation } from '~/utils/conversation';
import { useConversationTagsQuery } from './queries';
export const useUpdateConversationMutation = (
@@ -1468,7 +1469,12 @@ export const useDuplicateConversationMutation = (
queryClient.invalidateQueries([QueryKeys.project, duplicatedConversation.chatProjectId]);
}
- if (duplicatedConversation.tags && duplicatedConversation.tags.length > 0) {
+ /* The server counts no tags for a copy that retention keeps hidden, so neither does the cache. */
+ if (
+ duplicatedConversation.tags &&
+ duplicatedConversation.tags.length > 0 &&
+ !isTemporaryConversation(duplicatedConversation)
+ ) {
queryClient.setQueryData([QueryKeys.conversationTags], (oldTags) => {
if (!oldTags) return oldTags;
return oldTags.map((tag) => {
@@ -1525,7 +1531,12 @@ export const useForkConvoMutation = (
queryClient.invalidateQueries([QueryKeys.project, forkedConversation.chatProjectId]);
}
- if (forkedConversation.tags && forkedConversation.tags.length > 0) {
+ /* The server counts no tags for a copy that retention keeps hidden, so neither does the cache. */
+ if (
+ forkedConversation.tags &&
+ forkedConversation.tags.length > 0 &&
+ !isTemporaryConversation(forkedConversation)
+ ) {
queryClient.setQueryData([QueryKeys.conversationTags], (oldTags) => {
if (!oldTags) return oldTags;
return oldTags.map((tag) => {
diff --git a/client/src/hooks/Chat/useBookmarkItems.tsx b/client/src/hooks/Chat/useBookmarkItems.tsx
index be4ed7c0c74..56e509de254 100644
--- a/client/src/hooks/Chat/useBookmarkItems.tsx
+++ b/client/src/hooks/Chat/useBookmarkItems.tsx
@@ -3,14 +3,16 @@ import { useRecoilValue } from 'recoil';
import { BookmarkPlusIcon } from 'lucide-react';
import { useToastContext } from '@librechat/client';
import { useQueryClient } from '@tanstack/react-query';
-import { Constants, QueryKeys } from 'librechat-data-provider';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
-
+import { Constants, QueryKeys, isForcedTemporaryRetention } from 'librechat-data-provider';
import type { TConversationTag } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import type * as t from '~/common';
-
-import { useConversationTagsQuery, useTagConversationMutation } from '~/data-provider';
+import {
+ useConversationTagsQuery,
+ useGetStartupConfig,
+ useTagConversationMutation,
+} from '~/data-provider';
import { BookmarkEditDialog } from '~/components/Bookmarks';
import { useBookmarkSuccess, useLocalize } from '~/hooks';
import { isTemporaryConversation, logger } from '~/utils';
@@ -40,11 +42,19 @@ export default function useBookmarkItems({
const queryClient = useQueryClient();
const { showToast } = useToastContext();
+ const { data: startupConfig } = useGetStartupConfig();
const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined;
const conversationId = conversation?.conversationId ?? '';
const updateConvoTags = useBookmarkSuccess(conversationId);
const tags = conversation?.tags;
- const isTemporary = isTemporaryConversation(conversation);
+ /**
+ * A pre-existing permanent chat loaded under forced (ephemeral) retention stays
+ * non-temporary until the server converts it on the next write, so gate on the forced flag too
+ * to keep permanent-chat bookmarking hidden on a chat that is already effectively temporary.
+ */
+ const isTemporary =
+ isTemporaryConversation(conversation) ||
+ isForcedTemporaryRetention(startupConfig?.interface?.retentionMode);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const newBookmarkRef = useRef(null);
diff --git a/client/src/hooks/Chat/useTemporaryChat.ts b/client/src/hooks/Chat/useTemporaryChat.ts
index 21162305d6e..759b8d8e2ea 100644
--- a/client/src/hooks/Chat/useTemporaryChat.ts
+++ b/client/src/hooks/Chat/useTemporaryChat.ts
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
-import { Constants } from 'librechat-data-provider';
import { useRecoilState, useRecoilValue } from 'recoil';
-
+import { Constants, isForcedTemporaryRetention } from 'librechat-data-provider';
+import { useGetStartupConfig } from '~/data-provider';
import store from '~/store';
export type UseTemporaryChatResult = {
@@ -10,28 +10,37 @@ export type UseTemporaryChatResult = {
isTemporary: boolean;
/** Temporary mode is locked in for the conversation in progress, leaving only a read-only indicator. */
isActive: boolean;
+ /** The administrator forces temporary mode, so the toggle is read-only rather than absent. */
+ isEnforced: boolean;
toggle: () => void;
};
export default function useTemporaryChat(): UseTemporaryChatResult {
+ const { data: startupConfig } = useGetStartupConfig();
const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary);
const conversation = useRecoilValue(store.conversationByIndex(0));
const isSubmitting = useRecoilValue(store.isSubmittingFamily(0));
+ const isEnforced = isForcedTemporaryRetention(startupConfig?.interface?.retentionMode);
const toggle = useCallback(() => {
+ if (isEnforced) {
+ return;
+ }
setIsTemporary((previous) => !previous);
- }, [setIsTemporary]);
+ }, [isEnforced, setIsTemporary]);
const conversationId = conversation?.conversationId;
const hasStarted = conversationId != null && conversationId !== Constants.NEW_CONVO;
const hasMessages = Array.isArray(conversation?.messages) && conversation.messages.length >= 1;
const show = !hasStarted && !hasMessages && !isSubmitting;
+ const isForced = isEnforced || isTemporary;
return {
show,
- isTemporary,
- isActive: isTemporary && !show,
+ isTemporary: isForced,
+ isActive: isForced && !show,
+ isEnforced,
toggle,
};
}
diff --git a/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx b/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx
index ccd329b5190..1cdb74051e9 100644
--- a/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx
+++ b/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx
@@ -18,6 +18,8 @@ const mockUseStreamStatus = jest.fn();
const mockUseActiveJobs = jest.fn();
const mockUseAgentQueuedTurns = jest.fn();
const mockExtendActiveJobsGrace = jest.fn();
+let mockStartupConfig: { interface?: { retentionMode?: string } } | undefined;
+let mockStartupConfigSettled = true;
let mockFileMap: Record = {};
jest.mock('~/Providers', () => ({
@@ -39,6 +41,7 @@ jest.mock('~/data-provider', () => ({
ACTIVE_JOBS_SUCCESSOR_GRACE_MS: jest.requireActual('~/data-provider/SSE/queries')
.ACTIVE_JOBS_SUCCESSOR_GRACE_MS,
extendActiveJobsGrace: () => mockExtendActiveJobsGrace(),
+ useGetStartupConfig: () => ({ data: mockStartupConfig, isFetched: mockStartupConfigSettled }),
streamStatusQueryKey: (conversationId: string) => ['streamStatus', conversationId],
}));
@@ -400,6 +403,91 @@ describe('useResumeOnLoad', () => {
expect(jotaiStore.get(pendingApprovalActionFamily(CONVERSATION_ID))).toEqual(pendingAction);
});
+ /** A reloaded forced-temporary run has no conversation row to read yet, so only the
+ * status snapshot knows the run is hidden; a hard-coded `false` would send it into
+ * history caches and request a title the server never creates. */
+ it.each([true, false])(
+ 'rebuilds the submission with the server-recorded temporary state %s',
+ async (isTemporary) => {
+ const observedSubmissions: Array = [];
+ mockUseStreamStatus.mockReturnValue({
+ ...ACTIVE_STATUS,
+ data: { ...ACTIVE_STATUS.data, isTemporary },
+ });
+
+ renderUseResumeOnLoad({
+ messages: [buildUserMessage(CONVERSATION_ID)],
+ onSubmission: (currentSubmission) => observedSubmissions.push(currentSubmission),
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(observedSubmissions.at(-1)?.isTemporary).toBe(isTemporary);
+ },
+ );
+
+ /** A run admitted before the administrator forced ephemeral retention recorded
+ * `isTemporary: false`, but the resume converts it; the rebuilt submission must
+ * already treat it as hidden so it never bumps the chat in the history caches. */
+ it('treats a pre-policy run as temporary once retention is forced', async () => {
+ mockStartupConfig = { interface: { retentionMode: 'ephemeral' } };
+ const observedSubmissions: Array = [];
+ mockUseStreamStatus.mockReturnValue({
+ ...ACTIVE_STATUS,
+ data: { ...ACTIVE_STATUS.data, isTemporary: false },
+ });
+
+ try {
+ renderUseResumeOnLoad({
+ messages: [buildUserMessage(CONVERSATION_ID)],
+ onSubmission: (currentSubmission) => observedSubmissions.push(currentSubmission),
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(observedSubmissions.at(-1)?.isTemporary).toBe(true);
+ } finally {
+ mockStartupConfig = undefined;
+ }
+ });
+
+ /** A status snapshot that lands before the startup config would otherwise be
+ * rebuilt without knowing the retention mode, and the conversation would then
+ * be marked processed so the arriving config could never correct it. */
+ it('waits for the startup config before rebuilding the submission', async () => {
+ mockStartupConfigSettled = false;
+ const observedSubmissions: Array = [];
+ mockUseStreamStatus.mockReturnValue({
+ ...ACTIVE_STATUS,
+ data: { ...ACTIVE_STATUS.data, isTemporary: false },
+ });
+
+ try {
+ const { rerender } = renderUseResumeOnLoad({
+ messages: [buildUserMessage(CONVERSATION_ID)],
+ onSubmission: (currentSubmission) => observedSubmissions.push(currentSubmission),
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(observedSubmissions.filter(Boolean)).toHaveLength(0);
+
+ mockStartupConfig = { interface: { retentionMode: 'ephemeral' } };
+ mockStartupConfigSettled = true;
+ rerender();
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(observedSubmissions.at(-1)?.isTemporary).toBe(true);
+ } finally {
+ mockStartupConfig = undefined;
+ mockStartupConfigSettled = true;
+ }
+ });
+
/** The elapsed indicator's baseline must be the generation's real start:
* an attach with no surviving anchor (a reload, or a run another client
* started) rebuilds it clock-locally from the server-computed generation
diff --git a/client/src/hooks/SSE/useResumeOnLoad.ts b/client/src/hooks/SSE/useResumeOnLoad.ts
index 4dc45d8bcf7..8ac28e09cb8 100644
--- a/client/src/hooks/SSE/useResumeOnLoad.ts
+++ b/client/src/hooks/SSE/useResumeOnLoad.ts
@@ -7,6 +7,7 @@ import {
QueryKeys,
tMessageSchema,
isAssistantsEndpoint,
+ isForcedTemporaryRetention,
} from 'librechat-data-provider';
import type { TMessage, TConversation, TSubmission, Agents } from 'librechat-data-provider';
import type { GenerationProtocolVersion } from '~/data-provider/SSE/protocol';
@@ -27,6 +28,7 @@ import {
useStreamStatus,
useActiveJobs,
useAgentQueuedTurns,
+ useGetStartupConfig,
streamStatusQueryKey,
isQueuedTurnSuccessorOwed,
extendActiveJobsGrace,
@@ -150,6 +152,7 @@ function buildSubmissionFromResumeState(
conversationId: string,
generationCreatedAt?: number,
generationProtocolVersion: GenerationProtocolVersion = 1,
+ isTemporary = false,
): TSubmission {
const userMessageData = resumeState.userMessage;
const responseMessageId =
@@ -259,7 +262,7 @@ function buildSubmissionFromResumeState(
isRegenerate: isRegenerateResume,
...(isAnchoredRun && { compact: true }),
...(regenerateMessages && { regenerateMessages }),
- isTemporary: false,
+ isTemporary,
endpointOption: {},
// Signal to useResumableSSE to subscribe to existing stream instead of starting new
resumeStreamId: streamId,
@@ -304,6 +307,8 @@ export default function useResumeOnLoad(
const endpointType = currentConversation?.endpointType;
const actualEndpoint = endpointType ?? endpoint;
const resumableEnabled = !isAssistantsEndpoint(actualEndpoint);
+ const { data: startupConfig, isFetched: startupConfigSettled } = useGetStartupConfig();
+ const isRetentionForced = isForcedTemporaryRetention(startupConfig?.interface?.retentionMode);
// Track conversations we've already processed (either resumed or skipped)
const processedConvoRef = useRef(null);
/**
@@ -829,6 +834,7 @@ export default function useResumeOnLoad(
const shouldCheck =
resumableEnabled &&
messagesLoaded && // Wait for messages to load before checking
+ startupConfigSettled && // The forced retention mode decides the rebuilt submission's temporary state
!hasActiveSubmissionForThisConvo && // Allow if no submission or a confirmed stale submission
!!conversationId &&
conversationId !== Constants.NEW_CONVO &&
@@ -866,6 +872,10 @@ export default function useResumeOnLoad(
return;
}
+ if (!startupConfigSettled) {
+ return;
+ }
+
// Don't resume if we already have an active submission FOR THIS CONVERSATION
// A stale submission with undefined/different conversationId should not block us
if (hasActiveSubmissionForThisConvo) {
@@ -1056,6 +1066,7 @@ export default function useResumeOnLoad(
conversationId,
streamStatus.createdAt,
generationProtocolVersion,
+ streamStatus.isTemporary === true || isRetentionForced,
);
setSubmission(submission);
} else {
@@ -1073,7 +1084,7 @@ export default function useResumeOnLoad(
} as TMessage,
conversation: { conversationId, title: 'Resumed Chat' } as TConversation,
isRegenerate: false,
- isTemporary: false,
+ isTemporary: streamStatus.isTemporary === true || isRetentionForced,
endpointOption: {},
// Signal to useResumableSSE to subscribe to existing stream instead of starting new
resumeStreamId: streamStatus.streamId,
@@ -1112,6 +1123,8 @@ export default function useResumeOnLoad(
setActiveGenerationCreatedAt,
jotaiStore,
externalRunArm,
+ isRetentionForced,
+ startupConfigSettled,
]);
// Reset processedConvoRef when conversation changes to allow re-checking
diff --git a/client/src/hooks/useKeyboardShortcuts.ts b/client/src/hooks/useKeyboardShortcuts.ts
index 4f837e155d1..933203aac30 100644
--- a/client/src/hooks/useKeyboardShortcuts.ts
+++ b/client/src/hooks/useKeyboardShortcuts.ts
@@ -3,8 +3,8 @@ import { useSetAtom } from 'jotai';
import copy from 'copy-to-clipboard';
import { useToastContext } from '@librechat/client';
import { useMatch, useNavigate } from 'react-router-dom';
-import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
+import { PermissionTypes, Permissions, isForcedTemporaryRetention } from 'librechat-data-provider';
import type { ShortcutBinding } from '~/utils/shortcuts';
import type { ShortcutOverride } from '~/store/misc';
import {
@@ -15,9 +15,9 @@ import {
isMacPlatform,
parseBinding,
} from '~/utils/shortcuts';
+import { useArchiveConvoMutation, useGetStartupConfig } from '~/data-provider';
import { mainTextareaId, NotificationSeverity } from '~/common';
import useSidebarToggle from '~/hooks/Nav/useSidebarToggle';
-import { useArchiveConvoMutation } from '~/data-provider';
import { showFilesDialogAtom } from '~/store/filesDialog';
import { useHasAccess, useLocalize } from '~/hooks';
import { getFocusedChatPane } from '~/utils/pane';
@@ -556,6 +556,8 @@ export function useShortcutActions(): ShortcutAction[] {
permissionType: PermissionTypes.TEMPORARY_CHAT,
permission: Permissions.USE,
});
+ const { data: startupConfig } = useGetStartupConfig();
+ const isRetentionEnforced = isForcedTemporaryRetention(startupConfig?.interface?.retentionMode);
const archiveMutation = useArchiveConvoMutation();
@@ -748,7 +750,7 @@ export function useShortcutActions(): ShortcutAction[] {
}, []);
const handleToggleTemporaryChat = useCallback(() => {
- if (hasAccessToTemporaryChat !== true) {
+ if (hasAccessToTemporaryChat !== true || isRetentionEnforced) {
return false;
}
if (!routeConvoId) {
@@ -762,6 +764,7 @@ export function useShortcutActions(): ShortcutAction[] {
return true;
}, [
hasAccessToTemporaryChat,
+ isRetentionEnforced,
routeConvoId,
conversation?.messages,
isSubmitting,
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 582efa088c8..3e7c98cc5e6 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -2886,6 +2886,7 @@
"com_ui_teach_or_explain": "Learning",
"com_ui_temporary": "Temporary Chat",
"com_ui_temporary_description": "This chat won't appear in your history and will be deleted automatically.",
+ "com_ui_temporary_enforced": "Temporary Chat is enabled for all chats by your administrator",
"com_ui_terms_and_conditions": "Terms and Conditions",
"com_ui_terms_of_service": "Terms of service",
"com_ui_text_variables": "Text variables",
diff --git a/client/src/routes/ChatRoute.tsx b/client/src/routes/ChatRoute.tsx
index 27e9f437a65..6a3b7f4ee14 100644
--- a/client/src/routes/ChatRoute.tsx
+++ b/client/src/routes/ChatRoute.tsx
@@ -4,7 +4,12 @@ import { useRecoilCallback, useRecoilValue } from 'recoil';
import { useParams, useSearchParams } from 'react-router-dom';
import { Button, Spinner, useToastContext } from '@librechat/client';
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
-import { Constants, EModelEndpoint, PermissionBits } from 'librechat-data-provider';
+import {
+ Constants,
+ EModelEndpoint,
+ PermissionBits,
+ isForcedTemporaryRetention,
+} from 'librechat-data-provider';
import type { TPreset, TAgentsMap } from 'librechat-data-provider';
import {
defaultSpecAwaitsAgents,
@@ -152,16 +157,17 @@ export default function ChatRoute() {
);
const isTemporaryChat = isTemporaryConversation(conversation);
+ const forceTemporaryChat = isForcedTemporaryRetention(startupConfig?.interface?.retentionMode);
useEffect(() => {
if (conversationId === Constants.NEW_CONVO) {
- setIsTemporary(defaultTemporaryChat);
- } else if (isTemporaryChat) {
- setIsTemporary(isTemporaryChat);
+ setIsTemporary(forceTemporaryChat || defaultTemporaryChat);
+ } else if (forceTemporaryChat || isTemporaryChat) {
+ setIsTemporary(true);
} else {
setIsTemporary(false);
}
- }, [conversationId, isTemporaryChat, setIsTemporary, defaultTemporaryChat]);
+ }, [conversationId, isTemporaryChat, setIsTemporary, defaultTemporaryChat, forceTemporaryChat]);
/** This effect is mainly for the first conversation state change on first load of the page.
* Adjusting this may have unintended consequences on the conversation state.
diff --git a/client/src/utils/convos.spec.ts b/client/src/utils/convos.spec.ts
index 539632dad0d..235f6c81136 100644
--- a/client/src/utils/convos.spec.ts
+++ b/client/src/utils/convos.spec.ts
@@ -1098,6 +1098,24 @@ describe('Conversation Utilities', () => {
expect(data!.pages[0].conversations.map((c) => c.conversationId)).toEqual(['a']);
});
+ it('addConvoToAllQueries keeps a forced-temporary copy out of the list', () => {
+ addConvoToAllQueries(queryClient, { ...convoB, isTemporary: true } as TConversation);
+ const data = queryClient.getQueryData>([
+ 'allConversations',
+ ]);
+
+ expect(data!.pages[0].conversations.map((c) => c.conversationId)).toEqual(['a']);
+ });
+
+ it('addConvoToAllQueries still inserts an ordinary copy', () => {
+ addConvoToAllQueries(queryClient, { ...convoB, isTemporary: false } as TConversation);
+ const data = queryClient.getQueryData>([
+ 'allConversations',
+ ]);
+
+ expect(data!.pages[0].conversations.map((c) => c.conversationId)).toContain('b');
+ });
+
it('upsertConvoInAllQueries keeps legacy expiring conversations out of the list', () => {
upsertConvoInAllQueries(queryClient, {
...convoB,
diff --git a/client/src/utils/convos.ts b/client/src/utils/convos.ts
index dfa67474cb0..35384836ead 100644
--- a/client/src/utils/convos.ts
+++ b/client/src/utils/convos.ts
@@ -658,6 +658,14 @@ export function storeEndpointSettings(conversation: TConversation | null) {
// Add
export function addConvoToAllQueries(queryClient: QueryClient, newConvo: TConversation) {
+ /* Same reason the upsert path refuses one: the history query excludes temporary
+ conversations server-side, so a fork, duplicate or import made while retention
+ forces temporary mode would sit in the sidebar until a refetch removed it, and
+ would stay there if that refetch failed. */
+ if (isTemporaryConversation(newConvo)) {
+ return;
+ }
+
for (const query of findConversationListQueries(queryClient)) {
/* The unpin path reinserts a row that the update helper may have just marked stale;
seeding it at page one would clear that invalidation and fabricate a position. */
diff --git a/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
new file mode 100644
index 00000000000..a0e10b36682
--- /dev/null
+++ b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
@@ -0,0 +1,514 @@
+import { randomUUID } from 'crypto';
+import { expect, test } from '@playwright/test';
+import type { APIRequestContext, Page } from '@playwright/test';
+import type { AgentDetail } from '../agents.helpers';
+import { cleanupAgent, openAgentBuilder, uniqueAgentName } from '../agents.helpers';
+import { getE2EUser } from '../../../setup/user';
+import { loginAdmin, requestResult } from '../content-filters.helpers';
+import { deleteConversations, deleteMessagesByConversation, withMongo } from '../db';
+import {
+ MOCK_ENDPOINTS,
+ NEW_CHAT_PATH,
+ mockReply,
+ getAccessToken,
+ messagesView,
+ requestJson,
+ sendMessage,
+ selectMockEndpoint,
+ sendMessageAndWaitForCompletion,
+} from '../helpers';
+
+/**
+ * `retentionMode: "ephemeral"` is administrator state, so every scenario sets it as a
+ * stored config override for the signed-in user and clears it afterwards. The default
+ * mode is exercised by leaving the override off, which is what proves the feature is
+ * opt-in rather than a change to how LibreChat saves chats today.
+ */
+
+const userEmail = getE2EUser().email;
+const NO_PARENT = '00000000-0000-0000-0000-000000000000';
+const cleanupConversationIds: string[] = [];
+
+type StoredConversation = {
+ conversationId: string;
+ isTemporary?: boolean;
+ expiredAt?: Date | null;
+ tags?: string[];
+};
+
+type StoredMessage = StoredConversation & { messageId: string };
+
+async function resolveUserId(): Promise {
+ return withMongo(async (db) => {
+ const user = await db.collection('users').findOne({ email: userEmail });
+ if (!user) {
+ throw new Error(`E2E seed: user "${userEmail}" not found`);
+ }
+ return user._id.toString();
+ });
+}
+
+async function setRetentionMode(
+ request: APIRequestContext,
+ token: string,
+ userId: string,
+): Promise {
+ const result = await requestResult(request, {
+ path: `/api/admin/config/user/${encodeURIComponent(userId)}`,
+ token,
+ method: 'PUT',
+ data: {
+ overrides: { interface: { retentionMode: 'ephemeral', temporaryChatRetention: 1 } },
+ priority: 50,
+ },
+ });
+ expect(result.ok, `Expected the retention override to be stored: ${result.text}`).toBe(true);
+}
+
+async function clearRetentionMode(
+ request: APIRequestContext,
+ token: string,
+ userId: string,
+): Promise {
+ await requestResult(request, {
+ path: `/api/admin/config/user/${encodeURIComponent(userId)}`,
+ token,
+ method: 'DELETE',
+ });
+}
+
+async function seedPermanentConversation(
+ userId: string,
+ messages: Record[],
+ conversationFields: Record = {},
+): Promise {
+ const conversationId = randomUUID();
+ cleanupConversationIds.push(conversationId);
+ await withMongo(async (db) => {
+ const now = new Date();
+ await db.collection('conversations').insertOne({
+ conversationId,
+ title: 'Permanent chat',
+ user: userId,
+ endpoint: 'Mock Provider A',
+ model: 'mock-model-a',
+ isArchived: false,
+ isTemporary: false,
+ createdAt: now,
+ updatedAt: now,
+ __v: 0,
+ ...conversationFields,
+ });
+ await db.collection('messages').insertMany(
+ messages.map((message, index) => ({
+ conversationId,
+ user: userId,
+ endpoint: 'Mock Provider A',
+ model: 'mock-model-a',
+ error: false,
+ unfinished: false,
+ isTemporary: false,
+ createdAt: new Date(now.getTime() + index * 1000),
+ updatedAt: new Date(now.getTime() + index * 1000),
+ __v: 0,
+ ...message,
+ })),
+ );
+ });
+ return conversationId;
+}
+
+async function readConversation(conversationId: string): Promise {
+ return withMongo(
+ async (db) =>
+ (await db
+ .collection('conversations')
+ .findOne({ conversationId })) as StoredConversation | null,
+ );
+}
+
+async function readMessages(conversationId: string): Promise {
+ return withMongo(
+ async (db) =>
+ (await db
+ .collection('messages')
+ .find({ conversationId })
+ .toArray()) as unknown as StoredMessage[],
+ );
+}
+
+function expectForcedTemporary(row: StoredConversation | StoredMessage | null): void {
+ expect(row, 'Expected the row to still exist').not.toBeNull();
+ expect(row?.isTemporary).toBe(true);
+ expect(row?.expiredAt).toBeInstanceOf(Date);
+}
+
+async function startMockChat(page: Page): Promise {
+ await page.goto(NEW_CHAT_PATH);
+ await mockReply(page);
+ await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
+}
+
+test.afterEach(async () => {
+ const conversationIds = cleanupConversationIds.splice(0);
+ if (conversationIds.length === 0) {
+ return;
+ }
+ await deleteMessagesByConversation(conversationIds);
+ await deleteConversations(conversationIds);
+});
+
+test.describe('ephemeral retention', () => {
+ let workerToken: string | undefined;
+ let token: string;
+ let userId: string;
+
+ /** One login per worker: a login per test across three projects exhausts the login limiter. */
+ test.beforeEach(async ({ request }) => {
+ workerToken ??= await loginAdmin(request);
+ token = workerToken;
+ userId = await resolveUserId();
+ });
+
+ test.afterEach(async ({ request }) => {
+ await clearRetentionMode(request, token, userId);
+ });
+
+ test('a new chat is saved temporary and stays out of history @scenario:a-new-chat-is-saved-temporary-under-ephemeral-retention', async ({
+ page,
+ request,
+ }) => {
+ await setRetentionMode(request, token, userId);
+ await startMockChat(page);
+
+ const response = await sendMessageAndWaitForCompletion(page, 'Forced temporary turn');
+ const conversationId = ((await response.json()) as { conversationId?: string }).conversationId;
+ expect(conversationId, 'the turn must identify its conversation').toBeTruthy();
+ cleanupConversationIds.push(conversationId as string);
+
+ expectForcedTemporary(await readConversation(conversationId as string));
+ const messages = await readMessages(conversationId as string);
+ expect(messages.length).toBeGreaterThan(0);
+ for (const message of messages) {
+ expectForcedTemporary(message);
+ }
+
+ const history = await requestResult(request, { path: '/api/convos?limit=25', token });
+ expect(history.text).not.toContain(conversationId as string);
+ });
+
+ test('resuming a chat after enabling ephemeral converts its parent @scenario:resuming-a-chat-after-enabling-ephemeral-converts-its-parent', async ({
+ page,
+ request,
+ }) => {
+ test.setTimeout(120000);
+ let agentId: string | undefined;
+ try {
+ await page.goto(NEW_CHAT_PATH);
+ const userToken = await getAccessToken(page);
+ await expect(async () => {
+ const tools = await requestJson<{
+ servers?: Record }>;
+ }>(page, { path: '/api/mcp/tools', token: userToken });
+ expect(tools.servers?.['e2e-memory']?.tools).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ pluginKey: 'approval_probe_mcp_e2e-memory' }),
+ ]),
+ );
+ }).toPass({ timeout: 30000 });
+ const agentName = uniqueAgentName('Retention resume');
+ const agent = await requestJson(page, {
+ path: '/api/agents',
+ token: userToken,
+ method: 'POST',
+ body: {
+ name: agentName,
+ instructions: 'Use the requested approval probe tools and report their results.',
+ provider: MOCK_ENDPOINTS[0].label,
+ model: MOCK_ENDPOINTS[0].model,
+ tools: ['sys__server__sys_mcp_e2e-memory', 'approval_probe_mcp_e2e-memory'],
+ },
+ });
+ agentId = agent.id;
+ const form = await openAgentBuilder(page);
+ await form.getByRole('combobox', { name: 'Agent', exact: true }).click();
+ await page.getByRole('option', { name: agentName }).click();
+ await form.getByRole('button', { name: 'Select Agent' }).click();
+ if ((page.viewportSize()?.width ?? 1280) <= 768) {
+ await page.getByTestId('close-sidebar-button').click();
+ await expect(page.locator('#mobile-drawer')).toHaveAttribute('inert', '');
+ }
+
+ const response = await sendMessage(page, `E2E_TOOL_APPROVAL:retention-${randomUUID()}`);
+ expect(response.ok()).toBe(true);
+ await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 });
+ await expect(messagesView(page).getByTestId('tool-approval').first()).toBeVisible({
+ timeout: 30000,
+ });
+ const conversationId = new URL(page.url()).pathname.replace('/c/', '');
+ cleanupConversationIds.push(conversationId);
+ expect((await readConversation(conversationId))?.isTemporary).not.toBe(true);
+
+ await setRetentionMode(request, token, userId);
+ await page.reload();
+ const panel = page.locator('#pending-tool-approval-panel');
+ await expect(panel).toBeVisible({ timeout: 30000 });
+ await panel.getByRole('button', { name: 'Collapse', exact: true }).click();
+ const card = messagesView(page).getByTestId('tool-approval').first();
+ await card.getByRole('button', { name: 'Approve', exact: true }).click();
+ await card.getByRole('button', { name: 'Submit', exact: true }).click();
+ await expect(
+ messagesView(page)
+ .getByText(/^E2E approval outcomes:/)
+ .last(),
+ ).toBeVisible({
+ timeout: 30000,
+ });
+
+ /** The resume converts the conversation before the turn runs and saves the response
+ * after its text streams, so wait on the response row, not on the conversation. */
+ await expect(async () => {
+ expectForcedTemporary(await readConversation(conversationId));
+ const storedMessages = await readMessages(conversationId);
+ const responseMessage = storedMessages.find((message) => message.isTemporary === true);
+ expect(responseMessage, 'the resumed response must be saved temporary').toBeDefined();
+ expectForcedTemporary(responseMessage ?? null);
+ }).toPass({ timeout: 10000 });
+ const history = await requestResult(request, { path: '/api/convos?limit=25', token });
+ expect(history.ok).toBe(true);
+ expect(history.text).not.toContain(conversationId);
+ } finally {
+ await cleanupAgent(page, agentId);
+ }
+ });
+
+ test('Assistant chats are forced temporary @scenario:assistant-chats-are-forced-temporary', async ({
+ request,
+ }) => {
+ await setRetentionMode(request, token, userId);
+ const created = await requestResult(request, {
+ path: '/api/assistants/v2',
+ token,
+ method: 'POST',
+ data: {
+ endpoint: 'assistants',
+ model: 'gpt-4o-mini',
+ name: `Retention Assistant ${randomUUID()}`,
+ instructions: 'Answer the user briefly.',
+ tools: [],
+ },
+ });
+ expect(created.ok, created.text).toBe(true);
+ const assistantId = (created.body as { id: string }).id;
+ expect(assistantId).toBeTruthy();
+ try {
+ const messageId = randomUUID();
+ const result = await requestResult(request, {
+ path: '/api/assistants/v2/chat',
+ token,
+ method: 'POST',
+ data: {
+ text: 'E2E_REPLY:temporary Assistant reply',
+ sender: 'User',
+ clientTimestamp: new Date().toISOString(),
+ isCreatedByUser: true,
+ parentMessageId: NO_PARENT,
+ conversationId: null,
+ messageId,
+ responseMessageId: `${messageId}_response`,
+ endpoint: 'assistants',
+ endpointType: 'assistants',
+ model: 'gpt-4o-mini',
+ assistant_id: assistantId,
+ files: [],
+ isTemporary: false,
+ isRegenerate: false,
+ error: false,
+ },
+ });
+ expect(result.ok, result.text).toBe(true);
+ expect(result.text).toContain('"final":true');
+ expect(result.text).not.toContain('event: error');
+ const conversationId = result.text.match(/"conversationId":"([^"]+)"/)?.[1];
+ if (!conversationId) {
+ throw new Error('Assistant completion did not identify its conversation');
+ }
+ cleanupConversationIds.push(conversationId);
+ expectForcedTemporary(await readConversation(conversationId));
+ const messages = await readMessages(conversationId);
+ expect(messages).toHaveLength(2);
+ for (const message of messages) {
+ expectForcedTemporary(message);
+ }
+ const history = await requestResult(request, { path: '/api/convos?limit=25', token });
+ expect(history.ok).toBe(true);
+ expect(history.text).not.toContain(conversationId);
+ } finally {
+ await requestResult(request, {
+ path: `/api/assistants/v2/${encodeURIComponent(assistantId)}?endpoint=assistants&model=gpt-4o-mini`,
+ token,
+ method: 'DELETE',
+ data: { endpoint: 'assistants' },
+ });
+ }
+ });
+
+ test('the temporary toggle is locked on @scenario:the-temporary-toggle-is-locked-on-under-ephemeral-retention', async ({
+ page,
+ request,
+ }) => {
+ await setRetentionMode(request, token, userId);
+ await startMockChat(page);
+
+ /** The header collapses its secondary actions into the overflow menu below `md`,
+ * so the enforced control a narrow viewport offers is the menu's checkbox item
+ * while a wide one offers the toggle. Both carry the same locked contract. */
+ if ((page.viewportSize()?.width ?? 0) < 768) {
+ await page.locator('#header-menu-button').click();
+ const item = page.getByRole('menuitemcheckbox', { name: /administrator/i });
+ await expect(item).toHaveAttribute('aria-checked', 'true');
+ await expect(item).toHaveAttribute('aria-disabled', 'true');
+ await item.click({ force: true });
+ await expect(item).toHaveAttribute('aria-checked', 'true');
+ return;
+ }
+
+ const toggle = page.getByRole('button', { name: /administrator/i });
+ await expect(toggle).toHaveAttribute('aria-pressed', 'true');
+ await expect(toggle).toHaveAttribute('aria-disabled', 'true');
+
+ /** aria-disabled keeps the control focusable and announced, which also makes
+ * Playwright treat it as not actionable, so the click has to be forced to
+ * prove the handler refuses it rather than that the element is unreachable. */
+ await toggle.focus();
+ await expect(toggle, 'an enforced control stays reachable by keyboard').toBeFocused();
+
+ await toggle.click({ force: true });
+ await expect(toggle).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ test('editing a message converts the chat holding it @scenario:editing-a-message-converts-its-pre-existing-chat', async ({
+ request,
+ }) => {
+ const messageId = randomUUID();
+ const conversationId = await seedPermanentConversation(userId, [
+ {
+ messageId,
+ parentMessageId: NO_PARENT,
+ isCreatedByUser: true,
+ text: 'Original question',
+ },
+ ]);
+
+ await setRetentionMode(request, token, userId);
+ const edit = await requestResult(request, {
+ path: `/api/messages/${encodeURIComponent(conversationId)}/${encodeURIComponent(messageId)}`,
+ token,
+ method: 'PUT',
+ data: { text: 'Edited question', model: 'mock-model-a' },
+ });
+ expect(edit.ok, `Expected the edit to succeed: ${edit.text}`).toBe(true);
+
+ expectForcedTemporary(await readConversation(conversationId));
+ const [message] = await readMessages(conversationId);
+ expectForcedTemporary(message);
+ });
+
+ test('branching a response converts the chat holding it @scenario:branching-a-response-converts-its-pre-existing-chat', async ({
+ request,
+ }) => {
+ const messageId = randomUUID();
+ const agentId = 'agent-mock-a';
+ const conversationId = await seedPermanentConversation(userId, [
+ {
+ messageId,
+ parentMessageId: NO_PARENT,
+ isCreatedByUser: false,
+ text: 'Parallel answer',
+ content: [
+ { type: 'text', text: 'First agent answer', agentId },
+ { type: 'text', text: 'Second agent answer', agentId: 'agent-mock-b' },
+ ],
+ },
+ ]);
+
+ await setRetentionMode(request, token, userId);
+ const branch = await requestResult(request, {
+ path: '/api/messages/branch',
+ token,
+ method: 'POST',
+ data: { messageId, agentId },
+ });
+ expect(branch.ok, `Expected the branch to be created: ${branch.text}`).toBe(true);
+
+ expectForcedTemporary(await readConversation(conversationId));
+ });
+
+ test('a duplicated chat is temporary and adds no bookmark counts @scenario:a-duplicated-chat-is-forced-temporary-without-bookmark-counts', async ({
+ request,
+ }) => {
+ const tag = `retention-${randomUUID().slice(0, 8)}`;
+ const conversationId = await seedPermanentConversation(
+ userId,
+ [
+ {
+ messageId: randomUUID(),
+ parentMessageId: NO_PARENT,
+ isCreatedByUser: true,
+ text: 'Bookmarked question',
+ },
+ ],
+ { tags: [tag] },
+ );
+ await withMongo(async (db) => {
+ await db
+ .collection('conversationtags')
+ .insertOne({ user: userId, tag, count: 1, position: 0, __v: 0 });
+ });
+
+ await setRetentionMode(request, token, userId);
+ const duplicate = await requestResult(request, {
+ path: '/api/convos/duplicate',
+ token,
+ method: 'POST',
+ data: { conversationId, title: 'Duplicated chat' },
+ });
+ expect(duplicate.ok, `Expected the duplicate to succeed: ${duplicate.text}`).toBe(true);
+ const duplicatedId = (duplicate.body as { conversation?: { conversationId?: string } })
+ ?.conversation?.conversationId;
+ expect(duplicatedId, 'the duplicate must identify its conversation').toBeTruthy();
+ cleanupConversationIds.push(duplicatedId as string);
+
+ const duplicated = await readConversation(duplicatedId as string);
+ expectForcedTemporary(duplicated);
+ expect(duplicated?.tags ?? [], 'a hidden copy must not store uncounted tags').toEqual([]);
+
+ const tagRow = await withMongo(async (db) =>
+ db.collection('conversationtags').findOne({ user: userId, tag }),
+ );
+ expect(tagRow?.count, 'a hidden chat must not raise its bookmark count').toBe(1);
+
+ await withMongo(async (db) => {
+ await db.collection('conversationtags').deleteOne({ user: userId, tag });
+ });
+ });
+
+ test('chats stay permanent under the default retention mode @scenario:chats-stay-permanent-under-the-default-retention-mode', async ({
+ page,
+ request,
+ }) => {
+ await startMockChat(page);
+
+ const response = await sendMessageAndWaitForCompletion(page, 'Ordinary saved turn');
+ const conversationId = ((await response.json()) as { conversationId?: string }).conversationId;
+ expect(conversationId, 'the turn must identify its conversation').toBeTruthy();
+ cleanupConversationIds.push(conversationId as string);
+
+ const conversation = await readConversation(conversationId as string);
+ expect(conversation?.isTemporary ?? false).toBe(false);
+ expect(conversation?.expiredAt ?? null).toBeNull();
+
+ const history = await requestResult(request, { path: '/api/convos?limit=25', token });
+ expect(history.text).toContain(conversationId as string);
+ });
+});
diff --git a/librechat.example.yaml b/librechat.example.yaml
index b5b5b4e8b44..f64f8c96cc4 100644
--- a/librechat.example.yaml
+++ b/librechat.example.yaml
@@ -375,8 +375,10 @@ interface:
# Defaults to the temporary chat retention policy when omitted.
# generalChatRetention: 2160 # 90 days, independently of temporary chats
# Existing stored deadlines are not migrated when these settings change.
- # Retention mode: "all" applies expiry to all data types, "temporary" (default) only to temporary chats
- # Before switching from "all" back to "temporary", remove retention deadlines from non-temporary data
+ # Retention mode: "temporary" (default) applies expiry only to chats users mark temporary;
+ # "all" applies expiry to all data types while keeping chats visible in history;
+ # "ephemeral" forces every chat to be temporary (always on, not user-toggleable) and applies expiry to all data.
+ # Before switching from "all"/"ephemeral" back to "temporary", remove retention deadlines from non-temporary data
# that should stop expiring:
# db.conversations.updateMany({ isTemporary: false, expiredAt: { $ne: null } }, { $unset: { expiredAt: 1 } })
# db.messages.updateMany({ isTemporary: false, expiredAt: { $ne: null } }, { $unset: { expiredAt: 1 } })
diff --git a/packages/api/src/app/permissions.spec.ts b/packages/api/src/app/permissions.spec.ts
index d12fe0e3fec..f99a9d02a1a 100644
--- a/packages/api/src/app/permissions.spec.ts
+++ b/packages/api/src/app/permissions.spec.ts
@@ -1,5 +1,11 @@
import { loadDefaultInterface } from '@librechat/data-schemas';
-import { SystemRoles, Permissions, PermissionTypes, roleDefaults } from 'librechat-data-provider';
+import {
+ SystemRoles,
+ Permissions,
+ roleDefaults,
+ RetentionMode,
+ PermissionTypes,
+} from 'librechat-data-provider';
import type { TConfigDefaults, TCustomConfig } from 'librechat-data-provider';
import type { AppConfig } from '@librechat/data-schemas';
import { updateInterfacePermissions } from './permissions';
@@ -204,6 +210,36 @@ describe('updateInterfacePermissions - permissions', () => {
);
});
+ it('does not rewrite a stored TEMPORARY_CHAT permission when retentionMode is ephemeral', async () => {
+ const config = {
+ interface: {
+ retentionMode: RetentionMode.EPHEMERAL,
+ temporaryChat: false,
+ },
+ };
+ const configDefaults = { interface: {} } as TConfigDefaults;
+ const interfaceConfig = await loadDefaultInterface({ config, configDefaults });
+ const appConfig = { config, interfaceConfig } as unknown as AppConfig;
+
+ await updateInterfacePermissions({
+ appConfig,
+ getRoleByName: mockGetRoleByName,
+ updateAccessPermissions: mockUpdateAccessPermissions,
+ });
+
+ /** The forced mode is overlaid where the control is rendered; persisting it here would
+ * survive a later return to `temporary` and silently grant access the operator removed. */
+ for (const role of [SystemRoles.USER, SystemRoles.ADMIN]) {
+ expect(mockUpdateAccessPermissions).toHaveBeenCalledWith(
+ role,
+ expect.objectContaining({
+ [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
+ }),
+ null,
+ );
+ }
+ });
+
it('should call updateAccessPermissions with false when permission types are false', async () => {
const config = {
interface: {
diff --git a/packages/api/src/auth/openid.spec.ts b/packages/api/src/auth/openid.spec.ts
index 543fa94fe38..57e9aedd7a0 100644
--- a/packages/api/src/auth/openid.spec.ts
+++ b/packages/api/src/auth/openid.spec.ts
@@ -1,12 +1,12 @@
import mongoose, { Types } from 'mongoose';
+import { ErrorTypes } from 'librechat-data-provider';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { logger, createMethods, createModels } from '@librechat/data-schemas';
-import { ErrorTypes } from 'librechat-data-provider';
import type { IUser, UserMethods } from '@librechat/data-schemas';
import type { CommandStartedEvent } from 'mongodb';
import type { FilterQuery } from 'mongoose';
-import { recordOpenIDUserLookup } from '~/app/metrics';
import { findOpenIDUser, getOpenIdEmail, getOpenIdIssuer, normalizeOpenIdIssuer } from './openid';
+import { recordOpenIDUserLookup } from '~/app/metrics';
function newId() {
return new Types.ObjectId();
@@ -843,7 +843,12 @@ describe('findOpenIDUser Mongo compatibility', () => {
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
- await mongoose.connect(mongoServer.getUri(), { monitorCommands: true });
+ /** Each test rebuilds the User indexes explicitly after resetting the database. */
+ await mongoose.connect(mongoServer.getUri(), {
+ monitorCommands: true,
+ autoCreate: false,
+ autoIndex: false,
+ });
createModels(mongoose);
User = mongoose.models.User as mongoose.Model;
methods = createMethods(mongoose);
diff --git a/packages/api/src/conversations/index.ts b/packages/api/src/conversations/index.ts
index 793a428ff20..e3397966faa 100644
--- a/packages/api/src/conversations/index.ts
+++ b/packages/api/src/conversations/index.ts
@@ -5,4 +5,5 @@ export * from './filters';
export * from './import';
export * from './lineage';
export * from './read';
+export * from './retention';
export * from './save';
diff --git a/packages/api/src/conversations/retention.spec.ts b/packages/api/src/conversations/retention.spec.ts
new file mode 100644
index 00000000000..ff10b617428
--- /dev/null
+++ b/packages/api/src/conversations/retention.spec.ts
@@ -0,0 +1,327 @@
+import { RetentionMode } from 'librechat-data-provider';
+import type { ForcedRetentionStore } from './retention';
+import {
+ applyForcedTemporaryRequest,
+ applyForcedRetention,
+ resolveResumableRetention,
+ persistForcedTemporaryMetadata,
+ resolveImportRetentionFields,
+ resolveImportTagCounts,
+ isTemporaryRecord,
+} from './retention';
+
+describe('applyForcedRetention', () => {
+ const conversationId = 'conversation-1';
+ const messageId = 'message-1';
+ let store: { stampForcedRetention: jest.Mock };
+
+ const ctxFor = (retentionMode?: RetentionMode) => ({
+ userId: 'user-1',
+ isTemporary: false,
+ interfaceConfig: retentionMode == null ? undefined : { retentionMode },
+ });
+
+ beforeEach(() => {
+ store = { stampForcedRetention: jest.fn() };
+ });
+
+ const run = (ctx: ReturnType, messageIdArg?: string) =>
+ applyForcedRetention(store as unknown as ForcedRetentionStore, {
+ ctx,
+ conversationId,
+ ...(messageIdArg == null ? {} : { messageId: messageIdArg }),
+ });
+
+ it('stamps the conversation and the named message under ephemeral retention', async () => {
+ await run(ctxFor(RetentionMode.EPHEMERAL), messageId);
+
+ expect(store.stampForcedRetention).toHaveBeenCalledWith(
+ { userId: 'user-1', interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } },
+ { conversationId, messageIds: [messageId] },
+ );
+ });
+
+ it('stamps only the conversation when the caller already saved the message', async () => {
+ await run(ctxFor(RetentionMode.EPHEMERAL));
+
+ expect(store.stampForcedRetention).toHaveBeenCalledWith(expect.anything(), {
+ conversationId,
+ messageIds: [],
+ });
+ });
+
+ it('never forwards the caller-supplied temporary flag or deadline', async () => {
+ await applyForcedRetention(store as unknown as ForcedRetentionStore, {
+ ctx: {
+ userId: 'user-1',
+ isTemporary: false,
+ expiredAt: new Date('2099-01-01T00:00:00.000Z'),
+ interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL },
+ },
+ conversationId,
+ });
+
+ const [ctx] = store.stampForcedRetention.mock.calls[0];
+ expect(ctx).toEqual({
+ userId: 'user-1',
+ interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL },
+ });
+ });
+
+ it.each([undefined, null, ''])(
+ 'writes nothing when the message has no stored conversation (%s)',
+ async (storedConversationId) => {
+ await applyForcedRetention(store as unknown as ForcedRetentionStore, {
+ ctx: ctxFor(RetentionMode.EPHEMERAL),
+ conversationId: storedConversationId,
+ messageId,
+ });
+
+ expect(store.stampForcedRetention).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each([RetentionMode.TEMPORARY, RetentionMode.ALL, undefined])(
+ 'writes nothing under retentionMode %s',
+ async (retentionMode) => {
+ await run(ctxFor(retentionMode), messageId);
+
+ expect(store.stampForcedRetention).not.toHaveBeenCalled();
+ },
+ );
+});
+
+describe('applyForcedTemporaryRequest', () => {
+ it('overrides a pre-policy paused job along with the resume request', () => {
+ const req = {
+ body: { isTemporary: false },
+ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } },
+ };
+ const metadata = { isTemporary: false };
+
+ applyForcedTemporaryRequest(req, metadata);
+
+ expect(req.body.isTemporary).toBe(true);
+ expect(metadata.isTemporary).toBe(true);
+ });
+
+ it.each([false, undefined, 'false'])(
+ 'marks the request temporary under ephemeral whatever the client sent (%s)',
+ (isTemporary) => {
+ const req = {
+ body: { isTemporary },
+ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } },
+ };
+
+ applyForcedTemporaryRequest(req);
+
+ expect(req.body.isTemporary).toBe(true);
+ },
+ );
+
+ it.each([RetentionMode.TEMPORARY, RetentionMode.ALL, undefined])(
+ 'leaves the client flag alone under retentionMode %s',
+ (retentionMode) => {
+ const req = {
+ body: { isTemporary: false },
+ config: { interfaceConfig: retentionMode == null ? undefined : { retentionMode } },
+ };
+
+ applyForcedTemporaryRequest(req);
+
+ expect(req.body.isTemporary).toBe(false);
+ },
+ );
+
+ it('tolerates a request without a body', () => {
+ expect(() =>
+ applyForcedTemporaryRequest({
+ body: null,
+ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } },
+ }),
+ ).not.toThrow();
+ });
+});
+
+describe('persistForcedTemporaryMetadata', () => {
+ it.each([RetentionMode.TEMPORARY, RetentionMode.ALL, undefined])(
+ 'does not write job metadata under %s',
+ async (retentionMode) => {
+ const store = { updateMetadata: jest.fn() };
+ await persistForcedTemporaryMetadata(
+ { config: { interfaceConfig: { retentionMode } } },
+ { streamId: 'conversation-1', createdAt: 1000 },
+ store,
+ );
+ expect(store.updateMetadata).not.toHaveBeenCalled();
+ },
+ );
+
+ it('fences the persisted temporary flag to the generation being resumed', async () => {
+ const store = { updateMetadata: jest.fn() };
+ await persistForcedTemporaryMetadata(
+ { config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } },
+ { streamId: 'conversation-1', createdAt: 1000 },
+ store,
+ );
+ expect(store.updateMetadata).toHaveBeenCalledWith(
+ 'conversation-1',
+ { isTemporary: true },
+ 1000,
+ );
+ });
+});
+
+describe('resolveResumableRetention', () => {
+ const deadline = new Date('2030-01-01T00:00:00.000Z');
+ const createExpiration = jest.fn(() => deadline);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it.each([RetentionMode.TEMPORARY, undefined])(
+ 'keeps the stored temporary state without inventing a deadline under %s',
+ (retentionMode) => {
+ expect(
+ resolveResumableRetention(
+ {
+ body: { isTemporary: false },
+ resolvedConversation: { isTemporary: true },
+ config: { interfaceConfig: { retentionMode } },
+ },
+ createExpiration,
+ ),
+ ).toEqual({ isTemporary: true });
+ expect(createExpiration).not.toHaveBeenCalled();
+ },
+ );
+
+ it('preserves the bound deadline while forcing an ephemeral turn temporary', () => {
+ expect(
+ resolveResumableRetention(
+ {
+ _agentEventBindingRetention: { isTemporary: false, expiredAt: deadline },
+ resolvedConversation: { expiredAt: new Date('2031-01-01T00:00:00.000Z') },
+ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } },
+ },
+ createExpiration,
+ ),
+ ).toEqual({ isTemporary: true, retentionExpiresAt: deadline.toISOString() });
+ expect(createExpiration).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ { retentionMode: RetentionMode.ALL, isTemporary: false },
+ { retentionMode: RetentionMode.EPHEMERAL, isTemporary: true },
+ ])('captures a deadline for $retentionMode turns', ({ retentionMode, isTemporary }) => {
+ expect(
+ resolveResumableRetention(
+ { body: { isTemporary: false }, config: { interfaceConfig: { retentionMode } } },
+ createExpiration,
+ ),
+ ).toEqual({ isTemporary, retentionExpiresAt: deadline.toISOString() });
+ expect(createExpiration).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('resolveImportRetentionFields', () => {
+ const deps = {
+ createChatExpirationDate: jest.fn(() => new Date('2030-01-01T00:00:00.000Z')),
+ createFallbackRetentionDate: jest.fn(() => new Date('2031-01-01T00:00:00.000Z')),
+ logger: { error: jest.fn() },
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it.each([RetentionMode.TEMPORARY, undefined])('stores nothing under retentionMode %s', (mode) => {
+ expect(
+ resolveImportRetentionFields(mode == null ? undefined : { retentionMode: mode }, deps),
+ ).toEqual({});
+ expect(deps.createChatExpirationDate).not.toHaveBeenCalled();
+ });
+
+ it('gives an all-data import a deadline without marking it temporary', () => {
+ const fields = resolveImportRetentionFields({ retentionMode: RetentionMode.ALL }, deps);
+
+ expect(fields).toEqual({ isTemporary: false, expiredAt: new Date('2030-01-01T00:00:00.000Z') });
+ expect(deps.createChatExpirationDate).toHaveBeenCalledWith(
+ { retentionMode: RetentionMode.ALL },
+ false,
+ );
+ });
+
+ it('marks an ephemeral import temporary with the temporary-chat deadline', () => {
+ const interfaceConfig = { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 };
+ const fields = resolveImportRetentionFields(interfaceConfig, deps);
+
+ expect(fields).toEqual({ isTemporary: true, expiredAt: new Date('2030-01-01T00:00:00.000Z') });
+ expect(deps.createChatExpirationDate).toHaveBeenCalledWith(interfaceConfig, true);
+ });
+
+ it('keeps a copy of a temporary chat temporary under all-data retention', () => {
+ const interfaceConfig = { retentionMode: RetentionMode.ALL, generalChatRetention: 2160 };
+ const fields = resolveImportRetentionFields(interfaceConfig, deps, { sourceIsTemporary: true });
+
+ expect(fields.isTemporary).toBe(true);
+ expect(deps.createChatExpirationDate).toHaveBeenCalledWith(interfaceConfig, true);
+ });
+
+ it('leaves a copy of an ordinary chat visible under all-data retention', () => {
+ const interfaceConfig = { retentionMode: RetentionMode.ALL, generalChatRetention: 2160 };
+ const fields = resolveImportRetentionFields(interfaceConfig, deps, {
+ sourceIsTemporary: false,
+ });
+
+ expect(fields.isTemporary).toBe(false);
+ expect(deps.createChatExpirationDate).toHaveBeenCalledWith(interfaceConfig, false);
+ });
+
+ it('forces a copy temporary under ephemeral whatever its source was', () => {
+ const fields = resolveImportRetentionFields({ retentionMode: RetentionMode.EPHEMERAL }, deps, {
+ sourceIsTemporary: false,
+ });
+
+ expect(fields.isTemporary).toBe(true);
+ });
+
+ it('falls back rather than storing an import with no deadline', () => {
+ deps.createChatExpirationDate.mockImplementationOnce(() => {
+ throw new Error('bad retention window');
+ });
+
+ expect(resolveImportRetentionFields({ retentionMode: RetentionMode.EPHEMERAL }, deps)).toEqual({
+ isTemporary: true,
+ expiredAt: new Date('2031-01-01T00:00:00.000Z'),
+ });
+ expect(deps.logger.error).toHaveBeenCalled();
+ });
+});
+
+describe('resolveImportTagCounts', () => {
+ it('counts the tags of an import that stays visible', () => {
+ expect(resolveImportTagCounts({ isTemporary: false }, ['work', 'urgent'])).toEqual([
+ 'work',
+ 'urgent',
+ ]);
+ expect(resolveImportTagCounts({}, ['work'])).toEqual(['work']);
+ });
+
+ it('counts nothing for a forced-temporary import', () => {
+ expect(resolveImportTagCounts({ isTemporary: true }, ['work', 'urgent'])).toEqual([]);
+ });
+});
+
+describe('isTemporaryRecord', () => {
+ it('treats an explicit flag as authoritative', () => {
+ expect(isTemporaryRecord({ isTemporary: true })).toBe(true);
+ expect(isTemporaryRecord({ isTemporary: false, expiredAt: new Date() })).toBe(false);
+ });
+
+ it('classifies a legacy row with only a deadline as temporary', () => {
+ expect(isTemporaryRecord({ expiredAt: new Date() })).toBe(true);
+ expect(isTemporaryRecord({})).toBe(false);
+ });
+});
diff --git a/packages/api/src/conversations/retention.ts b/packages/api/src/conversations/retention.ts
new file mode 100644
index 00000000000..53878e54bfb
--- /dev/null
+++ b/packages/api/src/conversations/retention.ts
@@ -0,0 +1,167 @@
+import { isAllDataRetention, isForcedTemporaryRetention } from 'librechat-data-provider';
+import type { ConversationMethods } from '@librechat/data-schemas';
+import type { ConversationWriteContext, TurnConversationRequest } from './save';
+import type { GenerationJobMetadata } from '~/types';
+import { getConversationWriteContext } from './save';
+
+export type ForcedRetentionStore = Pick;
+
+export interface ForcedRetentionWrite {
+ ctx: ConversationWriteContext;
+ /** The stored owner of the written message; a write with none has nothing to re-stamp. */
+ conversationId?: string | null;
+ /** Stamps this message as well; omit when the caller already saved it through `saveMessage`. */
+ messageId?: string;
+}
+
+/**
+ * Restores forced-temporary retention after a write that bypasses it.
+ *
+ * `updateMessage`, feedback, and the artifact and branch routes touch rows without going through
+ * the retention-aware save path, so under `retentionMode: "ephemeral"` a write inside a chat that
+ * predates the setting would leave the message expiring while the conversation holding it stayed
+ * permanent and visible. The store's retention-only write reuses a stored deadline, never upserts,
+ * and never rewrites the conversation's message list.
+ */
+export async function applyForcedRetention(
+ { stampForcedRetention }: ForcedRetentionStore,
+ { ctx, conversationId, messageId }: ForcedRetentionWrite,
+): Promise {
+ if (!conversationId || !isForcedTemporaryRetention(ctx.interfaceConfig?.retentionMode)) {
+ return;
+ }
+
+ await stampForcedRetention(
+ { userId: ctx.userId, interfaceConfig: ctx.interfaceConfig },
+ { conversationId, messageIds: messageId == null ? [] : [messageId] },
+ );
+}
+
+export interface ForcedTemporaryRequest {
+ body?: { isTemporary?: boolean | string | null } | null;
+ config?: { interfaceConfig?: ConversationWriteContext['interfaceConfig'] } | null;
+}
+
+/**
+ * Marks a request temporary when the administrator forces it, so every reader of the request's
+ * own flag (title eligibility, the title generators, resumable job state) treats a chat the
+ * server will hide exactly like a temporary chat the user chose, whatever the client sent.
+ */
+export function applyForcedTemporaryRequest(
+ req: ForcedTemporaryRequest,
+ metadata?: Pick,
+): void {
+ if (req.body == null || !isForcedTemporaryRetention(req.config?.interfaceConfig?.retentionMode)) {
+ return;
+ }
+ req.body.isTemporary = true;
+ if (metadata != null) {
+ metadata.isTemporary = true;
+ }
+}
+
+/** Makes a converted resume's policy survive a later pause, reload, or configuration change. */
+export async function persistForcedTemporaryMetadata(
+ req: ForcedTemporaryRequest,
+ { streamId, createdAt }: { streamId: string; createdAt: number },
+ store: {
+ updateMetadata: (
+ streamId: string,
+ metadata: Pick,
+ expectedCreatedAt: number,
+ ) => Promise;
+ },
+): Promise {
+ if (!isForcedTemporaryRetention(req.config?.interfaceConfig?.retentionMode)) {
+ return;
+ }
+ await store.updateMetadata(streamId, { isTemporary: true }, createdAt);
+}
+
+/** Captures the effective retention policy for a paused turn without re-reading its records. */
+export function resolveResumableRetention(
+ req: TurnConversationRequest,
+ createChatExpirationDate: ImportRetentionDependencies['createChatExpirationDate'],
+): Pick {
+ const { isTemporary, expiredAt, interfaceConfig } = getConversationWriteContext(req);
+ const retention: Pick = {
+ isTemporary: isForcedTemporaryRetention(interfaceConfig?.retentionMode) || isTemporary,
+ };
+ if (expiredAt != null) {
+ retention.retentionExpiresAt = new Date(expiredAt).toISOString();
+ } else if (isAllDataRetention(interfaceConfig?.retentionMode)) {
+ retention.retentionExpiresAt = createChatExpirationDate(
+ interfaceConfig,
+ req.resolvedConversation?.isTemporary ?? req.body?.isTemporary,
+ ).toISOString();
+ }
+ return retention;
+}
+
+/** Whether a stored conversation is temporary, counting legacy rows that carry only a deadline. */
+export function isTemporaryRecord(record: {
+ isTemporary?: boolean | null;
+ expiredAt?: Date | string | null;
+}): boolean {
+ return record.isTemporary === true || (record.isTemporary == null && record.expiredAt != null);
+}
+
+export interface ImportRetentionFields {
+ isTemporary?: boolean;
+ expiredAt?: Date;
+}
+
+export interface ImportRetentionDependencies {
+ createChatExpirationDate: (
+ interfaceConfig?: ConversationWriteContext['interfaceConfig'],
+ isTemporary?: boolean,
+ ) => Date;
+ createFallbackRetentionDate: () => Date;
+ logger?: { error: (message: string, error?: unknown) => void };
+}
+
+/**
+ * The retention fields an imported, forked or duplicated record is stored with.
+ *
+ * Empty unless the mode expires all data. Under `ephemeral` the records are additionally
+ * marked temporary, which is what keeps an imported chat out of history and out of the
+ * bookmark counts; a deadline that cannot be computed falls back rather than storing a
+ * record with no expiration at all.
+ *
+ * A fork or duplicate passes `sourceIsTemporary` so a copy of a chat the user marked
+ * temporary keeps that classification under `all` instead of being published into history
+ * with the longer general deadline. A fresh import has no source and stays visible.
+ */
+export function resolveImportRetentionFields(
+ interfaceConfig: ConversationWriteContext['interfaceConfig'],
+ { createChatExpirationDate, createFallbackRetentionDate, logger }: ImportRetentionDependencies,
+ { sourceIsTemporary }: { sourceIsTemporary?: boolean } = {},
+): ImportRetentionFields {
+ if (!isAllDataRetention(interfaceConfig?.retentionMode)) {
+ return {};
+ }
+
+ const isTemporary =
+ isForcedTemporaryRetention(interfaceConfig?.retentionMode) || sourceIsTemporary === true;
+ try {
+ return { isTemporary, expiredAt: createChatExpirationDate(interfaceConfig, isTemporary) };
+ } catch (error) {
+ logger?.error('[resolveImportRetentionFields] Error creating import expiration date:', error);
+ return { isTemporary, expiredAt: createFallbackRetentionDate() };
+ }
+}
+
+/**
+ * The bookmark tags an import should store and count.
+ *
+ * Forced-temporary records are excluded from every bookmark-filtered conversation query and
+ * are removed by TTL without a matching decrement, so counting their tags would leave
+ * permanent phantom totals behind chats a user can never reach. They store none either, so an
+ * explicit delete never decrements tags the copy did not count.
+ */
+export function resolveImportTagCounts(
+ retention: ImportRetentionFields,
+ tags: readonly string[],
+): string[] {
+ return retention.isTemporary === true ? [] : [...tags];
+}
diff --git a/packages/api/src/files/retention.spec.ts b/packages/api/src/files/retention.spec.ts
index 272a0d3e5ca..3d8d49a3ee0 100644
--- a/packages/api/src/files/retention.spec.ts
+++ b/packages/api/src/files/retention.spec.ts
@@ -157,6 +157,43 @@ describe('retention helpers', () => {
});
});
+ it('keeps the source message deadline for a file under EPHEMERAL', async () => {
+ const sourceDeadline = new Date('2029-06-01T00:00:00.000Z');
+ const req = request({
+ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } },
+ });
+ req.fileRetentionSource = { isTemporary: true, expiredAt: sourceDeadline };
+
+ const result = await getRetentionExpiry(req, dependencies);
+
+ expect(result).toEqual({ expiredAt: sourceDeadline });
+ expect(dependencies.createExpirationDate).not.toHaveBeenCalled();
+ });
+
+ it('gives a legacy source row a temporary deadline under EPHEMERAL', async () => {
+ const req = request({
+ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } },
+ });
+ req.fileRetentionSource = { isTemporary: false };
+
+ await getRetentionExpiry(req, dependencies);
+
+ expect(dependencies.createExpirationDate).toHaveBeenCalledWith(
+ req.config?.interfaceConfig,
+ true,
+ );
+ });
+
+ it('returns expiry when retentionMode is EPHEMERAL', async () => {
+ const result = await getRetentionExpiry(
+ request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }),
+ dependencies,
+ );
+
+ expect(result).toEqual({ expiredAt: expirationDate });
+ expect(dependencies.getConvo).not.toHaveBeenCalled();
+ });
+
it('returns a fresh expiry when the conversation has an active expiration', async () => {
dependencies.getConvo.mockResolvedValue({
expiredAt: new Date(Date.now() + 60 * 60 * 1000),
@@ -531,6 +568,22 @@ describe('retention helpers', () => {
).resolves.toBe(expirationDate);
});
+ it('returns a fresh expiry for retentionMode EPHEMERAL conversations without an expiration', async () => {
+ dependencies.getConvo.mockResolvedValue({ expiredAt: null });
+
+ await expect(
+ getSharedLinkExpiration(
+ {
+ req: request({
+ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } },
+ }),
+ conversationId: 'convo-1',
+ },
+ dependencies,
+ ),
+ ).resolves.toBe(expirationDate);
+ });
+
it('returns an expired source conversation date so callers can reject the share', async () => {
const expiredAt = new Date(Date.now() - 60 * 60 * 1000);
dependencies.getConvo.mockResolvedValue({ expiredAt });
diff --git a/packages/api/src/files/retention.ts b/packages/api/src/files/retention.ts
index e3be4c1fc28..04f691806c3 100644
--- a/packages/api/src/files/retention.ts
+++ b/packages/api/src/files/retention.ts
@@ -1,5 +1,5 @@
-import { RetentionMode } from 'librechat-data-provider';
import { createFallbackRetentionDate } from '@librechat/data-schemas';
+import { isAllDataRetention, isForcedTemporaryRetention } from 'librechat-data-provider';
import type { AppConfig } from '@librechat/data-schemas';
type InterfaceConfig = AppConfig['interfaceConfig'];
@@ -113,22 +113,29 @@ async function computeRetentionExpiry(
dependencies: RetentionDependencies,
): Promise {
const interfaceConfig = req?.config?.interfaceConfig;
- const isRetentionAll = interfaceConfig?.retentionMode === RetentionMode.ALL;
+ const isForcedTemporary = isForcedTemporaryRetention(interfaceConfig?.retentionMode);
+ const isRetentionAll = isAllDataRetention(interfaceConfig?.retentionMode);
const conversationId = req?.body?.conversationId;
const userId = req?.user?.id;
+ /** A source message's stored deadline wins in every mode, so a file attached late in a
+ * chat never outlives the message and conversation that reference it. */
if (req?.fileRetentionSource != null) {
const source = req.fileRetentionSource;
const expiredAt = getConversationExpirationDate(source);
if (expiredAt != null) {
return { expiredAt };
}
- return isRetentionAll || source.isTemporary === true
- ? createRetentionExpiry(req, dependencies, source.isTemporary === true)
+ const isTemporary = isForcedTemporary || source.isTemporary === true;
+ return isRetentionAll || isTemporary
+ ? createRetentionExpiry(req, dependencies, isTemporary)
: {};
}
+ if (isForcedTemporary) {
+ return createRetentionExpiry(req, dependencies, true);
+ }
if (
isRetentionAll &&
- (interfaceConfig.generalChatRetention === undefined ||
+ (interfaceConfig?.generalChatRetention === undefined ||
(req?.body?.isTemporary != null && !(conversationId && userId)))
) {
return createRetentionExpiry(req, dependencies);
@@ -211,7 +218,7 @@ const shouldRetainPersistentAgentFile = ({
const interfaceConfig = req?.config?.interfaceConfig;
return (
isPersistentAgentResourceUpload({ messageAttachment, toolResource }) &&
- (interfaceConfig?.retentionMode !== RetentionMode.ALL ||
+ (!isAllDataRetention(interfaceConfig?.retentionMode) ||
interfaceConfig?.retainAgentFiles === true)
);
};
@@ -250,7 +257,7 @@ export async function getSharedLinkExpiration(
return undefined;
}
- const isRetentionAll = req?.config?.interfaceConfig?.retentionMode === RetentionMode.ALL;
+ const isRetentionAll = isAllDataRetention(req?.config?.interfaceConfig?.retentionMode);
const convo = await dependencies.getConvo(userId, conversationId);
if (!convo) {
return undefined;
diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts
index 75b059dfeac..f934fe79534 100644
--- a/packages/data-provider/specs/config-schemas.spec.ts
+++ b/packages/data-provider/specs/config-schemas.spec.ts
@@ -4,6 +4,8 @@ import {
azureEndpointSchema,
endpointSchema,
RetentionMode,
+ isAllDataRetention,
+ isForcedTemporaryRetention,
configSchema,
interfaceSchema,
fileStorageSchema,
@@ -1490,6 +1492,24 @@ describe('interfaceSchema', () => {
expect(result.defaultPinnedTools).toBeUndefined();
});
+
+ it('accepts the ephemeral retention mode', () => {
+ const result = interfaceSchema.parse({ retentionMode: RetentionMode.EPHEMERAL });
+ expect(result.retentionMode).toBe(RetentionMode.EPHEMERAL);
+ expect(RetentionMode.EPHEMERAL).toBe('ephemeral');
+ });
+
+ it('classifies ephemeral as forced-temporary, all-data retention', () => {
+ expect(isAllDataRetention(RetentionMode.EPHEMERAL)).toBe(true);
+ expect(isAllDataRetention(RetentionMode.ALL)).toBe(true);
+ expect(isAllDataRetention(RetentionMode.TEMPORARY)).toBe(false);
+ expect(isAllDataRetention(undefined)).toBe(false);
+
+ expect(isForcedTemporaryRetention(RetentionMode.EPHEMERAL)).toBe(true);
+ expect(isForcedTemporaryRetention(RetentionMode.ALL)).toBe(false);
+ expect(isForcedTemporaryRetention(RetentionMode.TEMPORARY)).toBe(false);
+ expect(isForcedTemporaryRetention(undefined)).toBe(false);
+ });
});
describe('summarizationTriggerSchema', () => {
diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts
index 5751cc8da00..1cc91f0dbbe 100644
--- a/packages/data-provider/src/config.ts
+++ b/packages/data-provider/src/config.ts
@@ -2250,6 +2250,7 @@ export function resolveTraceViewerConfig(
export enum RetentionMode {
ALL = 'all',
TEMPORARY = 'temporary',
+ EPHEMERAL = 'ephemeral',
}
const themeModeSchema = z
@@ -2299,6 +2300,14 @@ export function normalizeAgentSelectorLimit(value: unknown): number {
: DEFAULT_AGENT_SELECTOR_LIMIT;
}
+/** Retention modes that apply expiration deadlines to all data, not just user-marked temporary chats. */
+export const isAllDataRetention = (mode?: RetentionMode | null): boolean =>
+ mode === RetentionMode.ALL || mode === RetentionMode.EPHEMERAL;
+
+/** Whether the retention mode forces every conversation to be temporary, overriding the per-chat toggle. */
+export const isForcedTemporaryRetention = (mode?: RetentionMode | null): boolean =>
+ mode === RetentionMode.EPHEMERAL;
+
export const interfaceSchema = z
.object({
theme: deploymentThemeSchema.optional(),
diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts
index 6aee4aaa96c..99299f2d49e 100644
--- a/packages/data-schemas/src/methods/conversation.spec.ts
+++ b/packages/data-schemas/src/methods/conversation.spec.ts
@@ -14,6 +14,7 @@ import type {
IAgentEventActorSuspensionEvidence,
IChatProject,
IConversation,
+ IMessage,
AppConfig,
} from '../types';
import { ConversationMethods, createConversationMethods } from './conversation';
@@ -2239,6 +2240,30 @@ describe('Conversation Operations', () => {
expect(result?.isTemporary).toBe(false);
});
+ it('should force temporary conversation and set expiredAt when retentionMode is EPHEMERAL even if isTemporary is false', async () => {
+ mockCtx.isTemporary = false;
+ mockCtx.interfaceConfig = {
+ temporaryChatRetention: 24,
+ retentionMode: RetentionMode.EPHEMERAL,
+ };
+ const result = await saveConvo(mockCtx, mockConversationData);
+ expect(result?.isTemporary).toBe(true);
+ expect(result?.expiredAt).toBeDefined();
+ expect(result?.expiredAt).not.toBeNull();
+ });
+
+ it('should force temporary conversation when retentionMode is EPHEMERAL and isTemporary is omitted', async () => {
+ mockCtx.isTemporary = undefined;
+ mockCtx.interfaceConfig = {
+ temporaryChatRetention: 24,
+ retentionMode: RetentionMode.EPHEMERAL,
+ };
+ const result = await saveConvo(mockCtx, mockConversationData);
+ expect(result?.isTemporary).toBe(true);
+ expect(result?.expiredAt).toBeDefined();
+ expect(result?.expiredAt).not.toBeNull();
+ });
+
it('should filter out temporary conversations in getConvosByCursor', async () => {
// Create some test conversations
const newNonTemporaryConvo = await Conversation.create({
@@ -8857,3 +8882,154 @@ describe('Conversation Operations', () => {
});
});
});
+
+describe('stampForcedRetention', () => {
+ const ephemeral = { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 };
+ let userId: string;
+ let conversationId: string;
+ let messageId: string;
+
+ const MessageModel = () => mongoose.models.Message as mongoose.Model;
+
+ beforeEach(async () => {
+ userId = `stamp-${uuidv4()}`;
+ conversationId = uuidv4();
+ messageId = `message-${uuidv4()}`;
+ await Conversation.create({
+ conversationId,
+ user: userId,
+ title: 'Permanent',
+ endpoint: EModelEndpoint.openAI,
+ isTemporary: false,
+ tags: ['work'],
+ messages: [],
+ });
+ await MessageModel().create({
+ messageId,
+ conversationId,
+ user: userId,
+ text: 'hello',
+ isCreatedByUser: true,
+ isTemporary: false,
+ });
+ await ConversationTag.create({ user: userId, tag: 'work', count: 1, position: 0 });
+ });
+
+ it('converts the conversation and the named message and releases its bookmark count', async () => {
+ await methods.stampForcedRetention(
+ { userId, interfaceConfig: ephemeral },
+ { conversationId, messageIds: [messageId] },
+ );
+
+ const convo = await Conversation.findOne({ conversationId }).lean();
+ const message = await MessageModel().findOne({ messageId }).lean();
+ expect(convo?.isTemporary).toBe(true);
+ expect(convo?.expiredAt).toBeInstanceOf(Date);
+ expect(message?.isTemporary).toBe(true);
+ expect(message?.expiredAt).toEqual(convo?.expiredAt);
+ expect((await ConversationTag.findOne({ user: userId, tag: 'work' }).lean())?.count).toBe(0);
+ });
+
+ it('keeps the stored deadline instead of opening a new window', async () => {
+ const deadline = new Date(Date.now() + 5 * 60 * 1000);
+ await Conversation.updateOne({ conversationId }, { isTemporary: true, expiredAt: deadline });
+
+ await methods.stampForcedRetention(
+ { userId, interfaceConfig: ephemeral },
+ { conversationId, messageIds: [messageId] },
+ );
+
+ const convo = await Conversation.findOne({ conversationId }).lean();
+ const message = await MessageModel().findOne({ messageId }).lean();
+ expect(convo?.expiredAt).toEqual(deadline);
+ expect(message?.expiredAt).toEqual(deadline);
+ });
+
+ it('releases the bookmark count only once across repeated stamps', async () => {
+ const stamp = () =>
+ methods.stampForcedRetention({ userId, interfaceConfig: ephemeral }, { conversationId });
+ await Promise.all([stamp(), stamp(), stamp()]);
+
+ expect((await ConversationTag.findOne({ user: userId, tag: 'work' }).lean())?.count).toBe(0);
+ });
+
+ it('gives a stamped message the deadline a concurrent stamp stored first', async () => {
+ const winningDeadline = new Date(Date.now() + 24 * 60 * 60 * 1000);
+ const originalUpdateOne = Conversation.updateOne.bind(Conversation);
+ const updateOne = jest
+ .spyOn(Conversation, 'updateOne')
+ .mockImplementationOnce(((filter, update, options) =>
+ originalUpdateOne(
+ { conversationId },
+ { $set: { isTemporary: true, expiredAt: winningDeadline, tags: [] } },
+ { timestamps: false },
+ ).then(() => originalUpdateOne(filter, update, options))) as typeof Conversation.updateOne);
+
+ try {
+ await methods.stampForcedRetention(
+ { userId, interfaceConfig: ephemeral },
+ { conversationId, messageIds: [messageId] },
+ );
+ } finally {
+ updateOne.mockRestore();
+ }
+
+ const convo = await Conversation.findOne({ conversationId }).lean();
+ const message = await MessageModel().findOne({ messageId }).lean();
+ expect(convo?.expiredAt).toEqual(winningDeadline);
+ expect(message?.expiredAt).toEqual(winningDeadline);
+ });
+
+ it('does not release the bookmark count again when the converted chat is deleted', async () => {
+ await Conversation.create({
+ conversationId: uuidv4(),
+ user: userId,
+ endpoint: EModelEndpoint.openAI,
+ tags: ['work'],
+ });
+ await ConversationTag.updateOne({ user: userId, tag: 'work' }, { count: 2 });
+
+ await methods.stampForcedRetention({ userId, interfaceConfig: ephemeral }, { conversationId });
+ await methods.deleteConvos(userId, { conversationId });
+
+ expect((await ConversationTag.findOne({ user: userId, tag: 'work' }).lean())?.count).toBe(1);
+ });
+
+ it('never recreates a conversation or message that is gone', async () => {
+ await Conversation.deleteOne({ conversationId });
+ await MessageModel().deleteOne({ messageId });
+
+ await methods.stampForcedRetention(
+ { userId, interfaceConfig: ephemeral },
+ { conversationId, messageIds: [messageId] },
+ );
+
+ expect(await Conversation.countDocuments({ conversationId })).toBe(0);
+ expect(await MessageModel().countDocuments({ messageId })).toBe(0);
+ });
+
+ it('leaves the message list untouched', async () => {
+ const messageRef = new mongoose.Types.ObjectId();
+ await Conversation.updateOne({ conversationId }, { messages: [messageRef] });
+
+ await methods.stampForcedRetention({ userId, interfaceConfig: ephemeral }, { conversationId });
+
+ const convo = await Conversation.findOne({ conversationId }).lean();
+ expect(convo?.messages?.map(String)).toEqual([String(messageRef)]);
+ });
+
+ it.each([RetentionMode.TEMPORARY, RetentionMode.ALL, undefined])(
+ 'writes nothing under retentionMode %s',
+ async (retentionMode) => {
+ await methods.stampForcedRetention(
+ { userId, interfaceConfig: retentionMode == null ? undefined : { retentionMode } },
+ { conversationId, messageIds: [messageId] },
+ );
+
+ const convo = await Conversation.findOne({ conversationId }).lean();
+ expect(convo?.isTemporary).toBe(false);
+ expect(convo?.expiredAt ?? null).toBeNull();
+ expect((await ConversationTag.findOne({ user: userId, tag: 'work' }).lean())?.count).toBe(1);
+ },
+ );
+});
diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts
index 1a14e6d8257..1b556288d64 100644
--- a/packages/data-schemas/src/methods/conversation.ts
+++ b/packages/data-schemas/src/methods/conversation.ts
@@ -1,5 +1,5 @@
import { Buffer } from 'node:buffer';
-import { RetentionMode } from 'librechat-data-provider';
+import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provider';
import type {
AnyBulkWriteOperation,
DeleteResult,
@@ -292,6 +292,16 @@ export interface ConversationMethods {
replyMessageId?: string;
},
): Promise;
+ /**
+ * Stamps forced-temporary retention onto an existing conversation and, optionally, some of its
+ * messages, for writes that bypass the retention-aware save path. Reuses the stored deadline
+ * rather than opening a new window, never upserts, never rewrites `messages`, and releases the
+ * conversation's bookmark counts exactly once when it stops being visible.
+ */
+ stampForcedRetention(
+ ctx: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] },
+ target: { conversationId: string; messageIds?: string[] },
+ ): Promise;
setConvoPinned(
user: string,
conversationId: string,
@@ -2270,6 +2280,79 @@ export function createConversationMethods(
}
}
+ async function stampForcedRetention(
+ { userId, interfaceConfig }: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] },
+ { conversationId, messageIds = [] }: { conversationId: string; messageIds?: string[] },
+ ): Promise {
+ if (!userId || !isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
+ return;
+ }
+
+ const Conversation = mongoose.models.Conversation as Model;
+ const stored = await Conversation.findOne({ conversationId, user: userId })
+ .select({ _id: 1, isTemporary: 1, expiredAt: 1, tags: 1 })
+ .lean<{
+ _id: Types.ObjectId;
+ isTemporary?: boolean;
+ expiredAt?: Date | null;
+ tags?: string[];
+ } | null>();
+ if (!stored) {
+ return;
+ }
+
+ let expiredAt = stored.expiredAt ?? null;
+ if (expiredAt == null) {
+ try {
+ expiredAt = createTempChatExpirationDate(interfaceConfig);
+ } catch (err) {
+ logger.error('[stampForcedRetention] Error creating temporary chat expiration date:', err);
+ expiredAt = createFallbackRetentionDate();
+ }
+ }
+
+ let stampedDeadline = stored.expiredAt != null;
+ if (stored.isTemporary !== true) {
+ /**
+ * Conditional on the transition, so concurrent stamps release the bookmark counts once;
+ * the released tags are cleared with it so a later delete cannot release them again.
+ */
+ const converted = await Conversation.updateOne(
+ { _id: stored._id, isTemporary: { $ne: true } },
+ { $set: { isTemporary: true, expiredAt, tags: [] } },
+ { timestamps: false },
+ );
+ stampedDeadline ||= converted.modifiedCount > 0;
+ if (converted.modifiedCount > 0 && stored.tags?.length) {
+ await decrementTagCounts(mongoose, userId, stored.tags);
+ }
+ } else if (stored.expiredAt == null) {
+ const stamped = await Conversation.updateOne(
+ { _id: stored._id, expiredAt: null },
+ { $set: { expiredAt } },
+ { timestamps: false },
+ );
+ stampedDeadline = stamped.modifiedCount > 0;
+ }
+
+ if (messageIds.length === 0) {
+ return;
+ }
+ if (!stampedDeadline) {
+ /** A concurrent stamp set the conversation's deadline first; its messages share that one. */
+ const current = await Conversation.findOne({ _id: stored._id })
+ .select({ expiredAt: 1 })
+ .lean<{ expiredAt?: Date | null } | null>();
+ expiredAt = current?.expiredAt ?? expiredAt;
+ }
+ const Message = mongoose.models.Message as Model;
+ await Message.updateMany(
+ { user: userId, conversationId, messageId: { $in: messageIds } },
+ { $set: { isTemporary: true, expiredAt } },
+ { timestamps: false },
+ );
+ }
+
/**
* Saves a conversation to the database.
*/
@@ -2375,11 +2458,23 @@ export function createConversationMethods(
}
let retentionOnInsert: { expiredAt: Date; isTemporary: false } | undefined;
+ const forcedTemporary = isForcedTemporaryRetention(interfaceConfig?.retentionMode);
if (expiredAt instanceof Date && !Number.isNaN(expiredAt.getTime())) {
- if (typeof isTemporary === 'boolean') {
+ if (forcedTemporary) {
+ update.isTemporary = true;
+ } else if (typeof isTemporary === 'boolean') {
update.isTemporary = isTemporary;
}
update.expiredAt = expiredAt;
+ } else if (forcedTemporary) {
+ update.isTemporary = true;
+ try {
+ update.expiredAt = createTempChatExpirationDate(interfaceConfig);
+ } catch (err) {
+ logger.error('Error creating temporary chat expiration date:', err);
+ logger.info(`---\`saveConvo\` context: ${metadata?.context}`);
+ update.expiredAt = createFallbackRetentionDate();
+ }
} else if (interfaceConfig?.retentionMode === RetentionMode.ALL) {
if (typeof isTemporary === 'boolean') {
update.isTemporary = isTemporary;
@@ -4002,6 +4097,7 @@ export function createConversationMethods(
getConvoFiles,
searchConversation,
deleteNullOrEmptyConversations,
+ stampForcedRetention,
saveConvo,
setConvoPinned,
appendConvoMessageReference,
diff --git a/packages/data-schemas/src/methods/file.spec.ts b/packages/data-schemas/src/methods/file.spec.ts
index 426984d2e1e..4a867c5dcc9 100644
--- a/packages/data-schemas/src/methods/file.spec.ts
+++ b/packages/data-schemas/src/methods/file.spec.ts
@@ -4,7 +4,7 @@ import { MongoMemoryServer } from 'mongodb-memory-server';
import { EToolResources, FileContext } from 'librechat-data-provider';
import type { IChatProject, IMongoFile } from '~/types';
import { _resetStrictCache } from '~/models/plugins/tenantIsolation';
-import { runAsSystem } from '~/config/tenantContext';
+import { tenantStorage, runAsSystem } from '~/config/tenantContext';
import { createFileMethods } from './file';
import { createModels } from '~/models';
@@ -87,7 +87,6 @@ describe('File Methods', () => {
expect(file?.file_id).toBe(fileId);
expect(file?.expiresAt).toBeUndefined();
});
-
it('persists independent Code API pointers for both execution profiles', async () => {
const defaultRef = {
kind: 'user' as const,
@@ -119,6 +118,61 @@ describe('File Methods', () => {
expect(file?.metadata?.codeEnvRefs?.default?.file_id).toBe('default-file');
expect(file?.metadata?.codeEnvRefs?.stateful?.file_id).toBe('stateful-file');
});
+
+ it('casts string owner ids in atomic pipeline upserts', async () => {
+ const fileId = uuidv4();
+ const userId = new mongoose.Types.ObjectId();
+
+ const file = await fileMethods.createFile({
+ file_id: fileId,
+ user: userId.toString() as unknown as mongoose.Types.ObjectId,
+ filename: 'owned.txt',
+ filepath: '/uploads/owned.txt',
+ type: 'text/plain',
+ bytes: 100,
+ });
+
+ expect(file?.user).toEqual(userId);
+ await expect(File.countDocuments({ file_id: fileId, user: userId })).resolves.toBe(1);
+ });
+
+ it('rejects cross-tenant mutation fields before atomic pipeline upserts', async () => {
+ const userId = new mongoose.Types.ObjectId();
+
+ await expect(
+ tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
+ fileMethods.createFile({
+ file_id: uuidv4(),
+ user: userId,
+ tenantId: 'tenant-b',
+ filename: 'cross-tenant.txt',
+ filepath: '/uploads/cross-tenant.txt',
+ type: 'text/plain',
+ bytes: 100,
+ }),
+ ),
+ ).rejects.toThrow('Cross-tenant tenantId mutation is not allowed');
+ });
+
+ it('derives matching tenant ids from the tenant-scoped upsert filter', async () => {
+ const fileId = uuidv4();
+ const file = await tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
+ fileMethods.createFile({
+ file_id: fileId,
+ user: new mongoose.Types.ObjectId(),
+ tenantId: 'tenant-a',
+ filename: 'tenant-owned.txt',
+ filepath: '/uploads/tenant-owned.txt',
+ type: 'text/plain',
+ bytes: 100,
+ }),
+ );
+
+ expect(file?.tenantId).toBe('tenant-a');
+ await expect(
+ runAsSystem(() => File.countDocuments({ file_id: fileId, tenantId: 'tenant-a' })),
+ ).resolves.toBe(1);
+ });
});
describe('getAvailableProjectFiles', () => {
diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts
index 73a02695894..8d872211507 100644
--- a/packages/data-schemas/src/methods/message.spec.ts
+++ b/packages/data-schemas/src/methods/message.spec.ts
@@ -4492,6 +4492,30 @@ describe('Message Operations', () => {
expect(result?.expiredAt).toBeNull();
});
+ it('should force temporary message and set expiredAt when retentionMode is EPHEMERAL even if isTemporary is false', async () => {
+ mockCtx.isTemporary = false;
+ mockCtx.interfaceConfig = {
+ temporaryChatRetention: 24,
+ retentionMode: RetentionMode.EPHEMERAL,
+ };
+ const result = await saveMessage(mockCtx, mockMessageData);
+ expect(result?.isTemporary).toBe(true);
+ expect(result?.expiredAt).toBeDefined();
+ expect(result?.expiredAt).toBeInstanceOf(Date);
+ });
+
+ it('should force temporary message when retentionMode is EPHEMERAL and isTemporary is omitted', async () => {
+ mockCtx.isTemporary = undefined;
+ mockCtx.interfaceConfig = {
+ temporaryChatRetention: 24,
+ retentionMode: RetentionMode.EPHEMERAL,
+ };
+ const result = await saveMessage(mockCtx, mockMessageData);
+ expect(result?.isTemporary).toBe(true);
+ expect(result?.expiredAt).toBeDefined();
+ expect(result?.expiredAt).toBeInstanceOf(Date);
+ });
+
it('should handle missing config gracefully', async () => {
// Simulate missing config - should use default retention period
delete mockCtx.interfaceConfig;
diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts
index 23e932311a3..762950c1c6c 100644
--- a/packages/data-schemas/src/methods/message.ts
+++ b/packages/data-schemas/src/methods/message.ts
@@ -1,6 +1,7 @@
import {
backgroundResultMetadata,
HITL_MESSAGE_FILTER_FIELDS,
+ isForcedTemporaryRetention,
RetentionMode,
} from 'librechat-data-provider';
import type { DeleteResult, FilterQuery, Model, Types, UpdateQuery } from 'mongoose';
@@ -1095,11 +1096,23 @@ export function createMessageMethods(
delete update.expiredAt;
let retentionOnInsert: { expiredAt: Date; isTemporary: false } | undefined;
+ const forcedTemporary = isForcedTemporaryRetention(interfaceConfig?.retentionMode);
if (expiredAt instanceof Date && !Number.isNaN(expiredAt.getTime())) {
- if (typeof isTemporary === 'boolean') {
+ if (forcedTemporary) {
+ update.isTemporary = true;
+ } else if (typeof isTemporary === 'boolean') {
update.isTemporary = isTemporary;
}
update.expiredAt = expiredAt;
+ } else if (forcedTemporary) {
+ update.isTemporary = true;
+ try {
+ update.expiredAt = createTempChatExpirationDate(interfaceConfig);
+ } catch (err) {
+ logger.error('Error creating temporary chat expiration date:', err);
+ logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
+ update.expiredAt = createFallbackRetentionDate();
+ }
} else if (interfaceConfig?.retentionMode === RetentionMode.ALL) {
if (typeof isTemporary === 'boolean') {
update.isTemporary = isTemporary;