From 142931d8bec0e770171bd2e559948eb99d6f9ac6 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Tue, 22 Sep 2026 18:23:22 +0200
Subject: [PATCH 01/20] feat: add ephemeral retention mode for forced temporary
chats
Adds RetentionMode.EPHEMERAL, which forces every newly saved chat
temporary: hidden from history and search, given the temporary-chat
expiration, and with the toggle locked on.
Forces isTemporary and applies the temporary expiration in the normal
conversation and message save methods, routes Assistants/Threads
messages through the normal message save, applies the all-data
retention policy to file uploads and shared links, and stamps imports,
forks, and duplicates. Locks the frontend toggle and keyboard shortcut
behind an administrator-enforced badge.
The default temporary mode and the existing all mode are unchanged.
---
.../request.partialDisconnect.spec.js | 1 +
.../__tests__/request.resumeMetadata.spec.js | 35 ++
api/server/controllers/agents/request.js | 13 +-
.../__tests__/messages-content-edit.spec.js | 7 +-
.../__tests__/messages-feedback.spec.js | 1 +
.../routes/__tests__/messages-get.spec.js | 1 +
.../__tests__/messages-retention.spec.js | 237 ++++++++++++
.../routes/__tests__/messages-update.spec.js | 179 +++++++++
api/server/routes/__tests__/share.spec.js | 44 +--
.../agents/__tests__/streamTenant.spec.js | 19 +
api/server/routes/agents/index.js | 1 +
api/server/routes/convos.js | 2 +
api/server/routes/messages.js | 50 ++-
.../services/Threads/manage.retention.spec.js | 38 ++
api/server/utils/import/fork.js | 14 +-
api/server/utils/import/fork.spec.js | 80 +++-
api/server/utils/import/importBatchBuilder.js | 39 +-
api/server/utils/import/importers.spec.js | 17 +
client/src/components/Chat/Header.tsx | 9 +-
.../src/components/Chat/Menus/HeaderMenu.tsx | 8 +-
.../Menus/__tests__/BookmarkMenu.spec.tsx | 103 ++++++
client/src/components/Chat/TemporaryChat.tsx | 12 +-
.../Chat/__tests__/TemporaryChat.spec.tsx | 31 ++
client/src/data-provider/SSE/queries.ts | 3 +
.../__tests__/duplicateTagCounts.test.tsx | 70 ++++
client/src/data-provider/mutations.ts | 15 +-
client/src/hooks/Chat/useBookmarkItems.tsx | 20 +-
client/src/hooks/Chat/useTemporaryChat.ts | 19 +-
.../SSE/__tests__/useResumeOnLoad.spec.tsx | 24 ++
client/src/hooks/SSE/useResumeOnLoad.ts | 6 +-
client/src/hooks/useKeyboardShortcuts.ts | 9 +-
client/src/locales/en/translation.json | 1 +
client/src/routes/ChatRoute.tsx | 16 +-
client/src/utils/convos.spec.ts | 18 +
client/src/utils/convos.ts | 8 +
.../scenarios/ephemeral-retention.spec.ts | 346 ++++++++++++++++++
librechat.example.yaml | 6 +-
packages/api/src/app/permissions.spec.ts | 38 +-
packages/api/src/conversations/index.ts | 1 +
.../api/src/conversations/retention.spec.ts | 217 +++++++++++
packages/api/src/conversations/retention.ts | 112 ++++++
packages/api/src/files/retention.spec.ts | 53 +++
packages/api/src/files/retention.ts | 21 +-
.../specs/config-schemas.spec.ts | 20 +
packages/data-provider/src/config.ts | 9 +
.../src/methods/conversation.spec.ts | 134 +++++++
.../data-schemas/src/methods/conversation.ts | 87 ++++-
.../data-schemas/src/methods/file.spec.ts | 58 ++-
.../data-schemas/src/methods/message.spec.ts | 24 ++
packages/data-schemas/src/methods/message.ts | 15 +-
50 files changed, 2184 insertions(+), 107 deletions(-)
create mode 100644 api/server/routes/__tests__/messages-retention.spec.js
create mode 100644 api/server/routes/__tests__/messages-update.spec.js
create mode 100644 client/src/components/Chat/Menus/__tests__/BookmarkMenu.spec.tsx
create mode 100644 client/src/data-provider/__tests__/duplicateTagCounts.test.tsx
create mode 100644 e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
create mode 100644 packages/api/src/conversations/retention.spec.ts
create mode 100644 packages/api/src/conversations/retention.ts
diff --git a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js
index b725dd45cf8..83d22133dc0 100644
--- a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js
+++ b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js
@@ -49,6 +49,7 @@ jest.mock('@librechat/data-schemas', () => ({
jest.mock('@librechat/api', () => ({
getAgentErrorMetadata: (...args) =>
jest.requireActual('@librechat/api').getAgentErrorMetadata(...args),
+ applyForcedTemporaryRequest: jest.fn(),
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..f6cee61b6e0 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,7 @@ jest.mock('@librechat/api', () => ({
).getSteerRecoveryFailure,
getAgentErrorMetadata: (...args) =>
jest.requireActual('@librechat/api').getAgentErrorMetadata(...args),
+ applyForcedTemporaryRequest: jest.fn(),
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 +1013,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/request.js b/api/server/controllers/agents/request.js
index fa656d53ae3..2aa7fe31dbe 100644
--- a/api/server/controllers/agents/request.js
+++ b/api/server/controllers/agents/request.js
@@ -6,11 +6,14 @@ const {
ErrorTypes,
ViolationTypes,
isEphemeralAgentId,
+ isAllDataRetention,
+ isForcedTemporaryRetention,
} = require('librechat-data-provider');
const {
toPendingSteer,
persistedReasoningOverrideFields,
getViolationInfo,
+ applyForcedTemporaryRequest,
buildMessageFiles,
getReferencedQuotes,
resolveTitleTiming,
@@ -748,6 +751,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 {
@@ -1680,9 +1684,10 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
// 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,
+ isForcedTemporaryRetention(req.config?.interfaceConfig?.retentionMode) ||
+ (req._agentEventBindingRetention?.isTemporary ??
+ req.resolvedConversation?.isTemporary ??
+ req.body?.isTemporary),
...((req._agentEventBindingRetention?.expiredAt ?? req.resolvedConversation?.expiredAt) !=
null && {
retentionExpiresAt: new Date(
@@ -1691,7 +1696,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
}),
...((req._agentEventBindingRetention?.expiredAt ?? req.resolvedConversation?.expiredAt) ==
null &&
- req.config?.interfaceConfig?.retentionMode === 'all' && {
+ isAllDataRetention(req.config?.interfaceConfig?.retentionMode) && {
retentionExpiresAt: createChatExpirationDate(
req.config.interfaceConfig,
req.resolvedConversation?.isTemporary ?? req.body?.isTemporary,
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/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..49e45d28e5c 100644
--- a/api/server/utils/import/fork.js
+++ b/api/server/utils/import/fork.js
@@ -51,6 +51,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 +69,7 @@ async function forkConversation({
filters,
legacyPii,
builderFactory = createImportBatchBuilder,
+ interfaceConfig,
}) {
try {
const originalConvo = await getConvo(requestUserId, originalConvoId);
@@ -86,8 +88,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 = originalConvo.isTemporary === true;
importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI);
let messagesToClone = [];
@@ -479,6 +482,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 +492,7 @@ async function duplicateConversation({
userId,
conversationId,
title,
+ interfaceConfig,
filters,
legacyPii,
builderFactory = createImportBatchBuilder,
@@ -509,8 +514,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 = originalConvo.isTemporary === true;
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..b507f0f7fad 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,62 @@ 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('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('does not increment tag counts for 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([]);
+ });
+
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..dc604c3480e 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;
}
@@ -221,7 +209,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__/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..93f9523fb23 100644
--- a/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx
+++ b/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx
@@ -400,6 +400,30 @@ 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);
+ },
+ );
+
/** 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..17c64a1caa6 100644
--- a/client/src/hooks/SSE/useResumeOnLoad.ts
+++ b/client/src/hooks/SSE/useResumeOnLoad.ts
@@ -150,6 +150,7 @@ function buildSubmissionFromResumeState(
conversationId: string,
generationCreatedAt?: number,
generationProtocolVersion: GenerationProtocolVersion = 1,
+ isTemporary = false,
): TSubmission {
const userMessageData = resumeState.userMessage;
const responseMessageId =
@@ -259,7 +260,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,
@@ -1056,6 +1057,7 @@ export default function useResumeOnLoad(
conversationId,
streamStatus.createdAt,
generationProtocolVersion,
+ streamStatus.isTemporary === true,
);
setSubmission(submission);
} else {
@@ -1073,7 +1075,7 @@ export default function useResumeOnLoad(
} as TMessage,
conversation: { conversationId, title: 'Resumed Chat' } as TConversation,
isRegenerate: false,
- isTemporary: false,
+ isTemporary: streamStatus.isTemporary === true,
endpointOption: {},
// Signal to useResumableSSE to subscribe to existing stream instead of starting new
resumeStreamId: streamStatus.streamId,
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..7bd51733813
--- /dev/null
+++ b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
@@ -0,0 +1,346 @@
+import { randomUUID } from 'crypto';
+import { expect, test } from '@playwright/test';
+import type { APIRequestContext, Page } from '@playwright/test';
+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,
+ 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;
+};
+
+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 token: string;
+ let userId: string;
+
+ test.beforeEach(async ({ request }) => {
+ token = await loginAdmin(request);
+ 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('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);
+
+ expectForcedTemporary(await readConversation(duplicatedId as string));
+
+ 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/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..7e211f4ddf4
--- /dev/null
+++ b/packages/api/src/conversations/retention.spec.ts
@@ -0,0 +1,217 @@
+import { RetentionMode } from 'librechat-data-provider';
+import type { ForcedRetentionStore } from './retention';
+import {
+ applyForcedTemporaryRequest,
+ applyForcedRetention,
+ resolveImportRetentionFields,
+ resolveImportTagCounts,
+} 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.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('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([]);
+ });
+});
diff --git a/packages/api/src/conversations/retention.ts b/packages/api/src/conversations/retention.ts
new file mode 100644
index 00000000000..15365a5d3b3
--- /dev/null
+++ b/packages/api/src/conversations/retention.ts
@@ -0,0 +1,112 @@
+import { isAllDataRetention, isForcedTemporaryRetention } from 'librechat-data-provider';
+import type { ConversationMethods } from '@librechat/data-schemas';
+import type { ConversationWriteContext } 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): void {
+ if (req.body == null || !isForcedTemporaryRetention(req.config?.interfaceConfig?.retentionMode)) {
+ return;
+ }
+ req.body.isTemporary = true;
+}
+
+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 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.
+ */
+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..23444330b9c 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,112 @@ 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('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..e27874cf764 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,66 @@ 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();
+ }
+ }
+
+ if (stored.isTemporary !== true) {
+ /** Conditional on the transition, so concurrent stamps release the bookmark counts once. */
+ const converted = await Conversation.updateOne(
+ { _id: stored._id, isTemporary: { $ne: true } },
+ { $set: { isTemporary: true, expiredAt } },
+ { timestamps: false },
+ );
+ if (converted.modifiedCount > 0 && stored.tags?.length) {
+ await decrementTagCounts(mongoose, userId, stored.tags);
+ }
+ } else if (stored.expiredAt == null) {
+ await Conversation.updateOne(
+ { _id: stored._id, expiredAt: null },
+ { $set: { expiredAt } },
+ { timestamps: false },
+ );
+ }
+
+ if (messageIds.length === 0) {
+ return;
+ }
+ 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 +2445,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 +4084,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;
From 19500430ae238db3f3b55bd748ef32ac0db4bb8b Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 24 Sep 2026 17:02:29 +0200
Subject: [PATCH 02/20] fix: enforce ephemeral retention when resuming paused
chats
---
.../request.partialDisconnect.spec.js | 1 +
.../__tests__/request.resumeMetadata.spec.js | 1 +
.../agents/__tests__/resume.spec.js | 51 +++++++++++++
api/server/controllers/agents/request.js | 23 +-----
api/server/controllers/agents/resume.js | 12 ++++
.../scenarios/ephemeral-retention.spec.ts | 72 +++++++++++++++++++
.../api/src/conversations/retention.spec.ts | 67 +++++++++++++++++
packages/api/src/conversations/retention.ts | 32 ++++++++-
8 files changed, 236 insertions(+), 23 deletions(-)
diff --git a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js
index 83d22133dc0..05f4e55b7d2 100644
--- a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js
+++ b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js
@@ -50,6 +50,7 @@ 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 f6cee61b6e0..f4090c67661 100644
--- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
+++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
@@ -262,6 +262,7 @@ jest.mock('@librechat/api', () => ({
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,
diff --git a/api/server/controllers/agents/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js
index cf0ebef3f14..9544c7b55b6 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,55 @@ 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 } }),
+ );
+ 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(mockSaveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ isTemporary: true }),
+ expect.anything(),
+ expect.anything(),
+ );
+ expect(mockStampForcedRetention).toHaveBeenCalledWith(
+ { userId: USER_ID, interfaceConfig: requestConfigOverrides.interfaceConfig },
+ { conversationId: CONVO_ID, messageIds: [] },
+ );
+ expect(mockSaveMessage.mock.invocationCallOrder[0]).toBeLessThan(
+ mockStampForcedRetention.mock.invocationCallOrder[0],
+ );
+ const publication = rePause
+ ? mockGenerationJobManager.approvals.finishPausePersistence
+ : mockGenerationJobManager.publishTerminalClaim;
+ expect(mockStampForcedRetention.mock.invocationCallOrder[0]).toBeLessThan(
+ publication.mock.invocationCallOrder[0],
+ );
+ expect(mockStampConvoLastResponse).not.toHaveBeenCalled();
+ expect(mockAddTitle).not.toHaveBeenCalled();
+ },
+ );
+
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 2aa7fe31dbe..04f1ec40a06 100644
--- a/api/server/controllers/agents/request.js
+++ b/api/server/controllers/agents/request.js
@@ -6,14 +6,13 @@ const {
ErrorTypes,
ViolationTypes,
isEphemeralAgentId,
- isAllDataRetention,
- isForcedTemporaryRetention,
} = require('librechat-data-provider');
const {
toPendingSteer,
persistedReasoningOverrideFields,
getViolationInfo,
applyForcedTemporaryRequest,
+ resolveResumableRetention,
buildMessageFiles,
getReferencedQuotes,
resolveTitleTiming,
@@ -1683,25 +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:
- isForcedTemporaryRetention(req.config?.interfaceConfig?.retentionMode) ||
- (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 &&
- isAllDataRetention(req.config?.interfaceConfig?.retentionMode) && {
- 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/resume.js b/api/server/controllers/agents/resume.js
index 3cffe9b151b..5feb9067673 100644
--- a/api/server/controllers/agents/resume.js
+++ b/api/server/controllers/agents/resume.js
@@ -52,6 +52,8 @@ const {
PROJECT_CONTEXT_CHANGED_RESPONSE,
restoreScheduledTokenContext,
recoverTurnMessageReference,
+ applyForcedRetention,
+ applyForcedTemporaryRequest,
announceReply,
} = require('@librechat/api');
const { disposeClient } = require('~/server/cleanup');
@@ -86,6 +88,7 @@ const {
settleAgentEventActorDetachedAction,
appendConvoMessageReference,
stampConvoLastResponse,
+ stampForcedRetention,
} = require('~/models');
const {
acquireEventChildGenerationLease,
@@ -363,6 +366,10 @@ 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 },
+ );
await recoverResumedResponseReference(
{ userId, conversationId, client, savedResponseMessage },
'api/server/controllers/agents/resume.js - recovered re-paused response reference',
@@ -563,6 +570,10 @@ 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 },
+ );
await recoverResumedResponseReference(
{ userId, conversationId, client, savedResponseMessage },
'api/server/controllers/agents/resume.js - recovered resumed response reference',
@@ -1050,6 +1061,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;
diff --git a/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
index 7bd51733813..942ae8c867a 100644
--- a/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
+++ b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
@@ -1,6 +1,8 @@
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';
@@ -8,6 +10,10 @@ import {
MOCK_ENDPOINTS,
NEW_CHAT_PATH,
mockReply,
+ getAccessToken,
+ messagesView,
+ requestJson,
+ sendMessage,
selectMockEndpoint,
sendMessageAndWaitForCompletion,
} from '../helpers';
@@ -187,6 +193,72 @@ test.describe('ephemeral retention', () => {
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);
+ 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();
+
+ const response = await sendMessage(page, `E2E_TOOL_APPROVAL:retention-${randomUUID()}`);
+ expect(response.ok()).toBe(true);
+ 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,
+ });
+
+ 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);
+ 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('the temporary toggle is locked on @scenario:the-temporary-toggle-is-locked-on-under-ephemeral-retention', async ({
page,
request,
diff --git a/packages/api/src/conversations/retention.spec.ts b/packages/api/src/conversations/retention.spec.ts
index 7e211f4ddf4..50150175a2e 100644
--- a/packages/api/src/conversations/retention.spec.ts
+++ b/packages/api/src/conversations/retention.spec.ts
@@ -3,6 +3,7 @@ import type { ForcedRetentionStore } from './retention';
import {
applyForcedTemporaryRequest,
applyForcedRetention,
+ resolveResumableRetention,
resolveImportRetentionFields,
resolveImportTagCounts,
} from './retention';
@@ -89,6 +90,19 @@ describe('applyForcedRetention', () => {
});
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) => {
@@ -127,6 +141,59 @@ describe('applyForcedTemporaryRequest', () => {
});
});
+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')),
diff --git a/packages/api/src/conversations/retention.ts b/packages/api/src/conversations/retention.ts
index 15365a5d3b3..d1f332b7e70 100644
--- a/packages/api/src/conversations/retention.ts
+++ b/packages/api/src/conversations/retention.ts
@@ -1,6 +1,8 @@
import { isAllDataRetention, isForcedTemporaryRetention } from 'librechat-data-provider';
import type { ConversationMethods } from '@librechat/data-schemas';
-import type { ConversationWriteContext } from './save';
+import type { ConversationWriteContext, TurnConversationRequest } from './save';
+import type { GenerationJobMetadata } from '~/types';
+import { getConversationWriteContext } from './save';
export type ForcedRetentionStore = Pick;
@@ -45,11 +47,37 @@ export interface ForcedTemporaryRequest {
* 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): void {
+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;
+ }
+}
+
+/** 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;
}
export interface ImportRetentionFields {
From 05f7b45562a0a649bd7985f510413923bca64230 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 24 Sep 2026 17:22:07 +0200
Subject: [PATCH 03/20] fix: preserve forced retention across pauses and
assistant turns
---
.../agents/__tests__/resume.spec.js | 8 ++
api/server/controllers/agents/resume.js | 6 ++
.../assistants/chat.contentFilter.spec.js | 15 ++++
api/server/controllers/assistants/chatV1.js | 2 +
api/server/controllers/assistants/chatV2.js | 2 +
.../Endpoints/assistants/title.test.js | 11 +++
.../scenarios/ephemeral-retention.spec.ts | 82 +++++++++++++++++++
.../api/src/conversations/retention.spec.ts | 30 +++++++
packages/api/src/conversations/retention.ts | 18 ++++
9 files changed, 174 insertions(+)
diff --git a/api/server/controllers/agents/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js
index 9544c7b55b6..6ec9533eb74 100644
--- a/api/server/controllers/agents/__tests__/resume.spec.js
+++ b/api/server/controllers/agents/__tests__/resume.spec.js
@@ -3265,6 +3265,14 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
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(),
diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js
index 5feb9067673..01952cc88be 100644
--- a/api/server/controllers/agents/resume.js
+++ b/api/server/controllers/agents/resume.js
@@ -54,6 +54,7 @@ const {
recoverTurnMessageReference,
applyForcedRetention,
applyForcedTemporaryRequest,
+ persistForcedTemporaryMetadata,
announceReply,
} = require('@librechat/api');
const { disposeClient } = require('~/server/cleanup');
@@ -1901,6 +1902,11 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
req.turnStartedAt = job.createdAt;
+ await persistForcedTemporaryMetadata(
+ req,
+ { streamId, createdAt: job.createdAt },
+ GenerationJobManager,
+ );
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/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/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
index 942ae8c867a..34928c21836 100644
--- a/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
+++ b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
@@ -202,6 +202,16 @@ test.describe('ephemeral retention', () => {
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',
@@ -223,6 +233,7 @@ test.describe('ephemeral retention', () => {
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,
});
@@ -259,6 +270,77 @@ test.describe('ephemeral retention', () => {
}
});
+ 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,
diff --git a/packages/api/src/conversations/retention.spec.ts b/packages/api/src/conversations/retention.spec.ts
index 50150175a2e..604f598bef1 100644
--- a/packages/api/src/conversations/retention.spec.ts
+++ b/packages/api/src/conversations/retention.spec.ts
@@ -4,6 +4,7 @@ import {
applyForcedTemporaryRequest,
applyForcedRetention,
resolveResumableRetention,
+ persistForcedTemporaryMetadata,
resolveImportRetentionFields,
resolveImportTagCounts,
} from './retention';
@@ -141,6 +142,35 @@ describe('applyForcedTemporaryRequest', () => {
});
});
+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);
diff --git a/packages/api/src/conversations/retention.ts b/packages/api/src/conversations/retention.ts
index d1f332b7e70..eedba0798b3 100644
--- a/packages/api/src/conversations/retention.ts
+++ b/packages/api/src/conversations/retention.ts
@@ -60,6 +60,24 @@ export function applyForcedTemporaryRequest(
}
}
+/** 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,
From 5b0583812dbc422b32edbd9ccafe0034530721f4 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 24 Sep 2026 18:16:21 +0200
Subject: [PATCH 04/20] test: Use Real Module IDs for Installed Package Mocks
---
api/server/middleware/optionalShareFileAuth.spec.js | 7 ++++---
api/server/services/AuthService.spec.js | 7 ++++---
2 files changed, 8 insertions(+), 6 deletions(-)
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/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(),
From 543ecffb5b75b29c7627adbeee7808ff07e6945b Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 24 Sep 2026 18:22:41 +0200
Subject: [PATCH 05/20] test: Wait for Resumed Retention Persistence
---
e2e/specs/mock/scenarios/ephemeral-retention.spec.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
index 34928c21836..df002059baf 100644
--- a/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
+++ b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
@@ -257,7 +257,10 @@ test.describe('ephemeral retention', () => {
timeout: 30000,
});
- expectForcedTemporary(await readConversation(conversationId));
+ /** Streamed text can render before the resumed turn finishes its database writes. */
+ await expect(async () => {
+ expectForcedTemporary(await readConversation(conversationId));
+ }).toPass({ timeout: 10000 });
const storedMessages = await readMessages(conversationId);
const responseMessage = storedMessages.find((message) => message.isTemporary === true);
expect(responseMessage, 'the resumed response must be saved temporary').toBeDefined();
From 597866778ce5c4af71bbe30bec8026e656108478 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 24 Sep 2026 18:48:07 +0200
Subject: [PATCH 06/20] test: Control Mongo Index Setup in OpenID Fixture
---
packages/api/src/auth/openid.spec.ts | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
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);
From df0b099760111e72abca0e9d61ab6eec93324b70 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 24 Sep 2026 19:17:24 +0200
Subject: [PATCH 07/20] test: Close Mobile Drawer Before Retention Resume
Scenario
---
e2e/specs/mock/scenarios/ephemeral-retention.spec.ts | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
index df002059baf..17e2b7aca72 100644
--- a/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
+++ b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
@@ -230,6 +230,10 @@ test.describe('ephemeral retention', () => {
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);
From 637bd0e64081e2e6f705c1dbc96f6250dd196518 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 24 Sep 2026 19:30:50 +0200
Subject: [PATCH 08/20] fix: Store Responses API Messages Under the Retention
Write Context
Input and output messages were saved with the raw request, so under
ephemeral retention they missed the forced-temporary classification the
conversation received. Route both through getConversationWriteContext
and announce the stored reply as saved.
---
.../agents/__tests__/responses.unit.spec.js | 40 ++++++++++++++++++-
api/server/controllers/agents/responses.js | 22 +++-------
2 files changed, 45 insertions(+), 17 deletions(-)
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/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',
});
From 724ea4857fa63384faf9244a25b0c7feaf407ffb Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 24 Sep 2026 19:30:59 +0200
Subject: [PATCH 09/20] fix: Align Resumed Responses With the Parent Retention
Deadline
A resumed response was saved with a fresh forced-temporary window while
the re-stamp kept the conversation's earlier deadline, so a chat that
paused again could leave its response stored after the conversation
expired. Stamp the saved response with the parent's deadline.
---
.../controllers/agents/__tests__/resume.spec.js | 4 +++-
api/server/controllers/agents/resume.js | 12 ++++++++++--
2 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/api/server/controllers/agents/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js
index 6ec9533eb74..7d8bbdd00a6 100644
--- a/api/server/controllers/agents/__tests__/resume.spec.js
+++ b/api/server/controllers/agents/__tests__/resume.spec.js
@@ -3249,6 +3249,7 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
mockGenerationJobManager.getJob.mockResolvedValue(
makeToolApprovalJob({ metadata: { isTemporary: false } }),
);
+ mockSaveMessage.mockImplementation(async (_ctx, message) => message);
if (rePause) {
mockInitializeClient.mockResolvedValue({
client: makeClient({
@@ -3280,8 +3281,9 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
);
expect(mockStampForcedRetention).toHaveBeenCalledWith(
{ userId: USER_ID, interfaceConfig: requestConfigOverrides.interfaceConfig },
- { conversationId: CONVO_ID, messageIds: [] },
+ { conversationId: CONVO_ID, messageIds: [mockSaveMessage.mock.calls[0][1].messageId] },
);
+ expect(mockSaveMessage.mock.calls[0][1].messageId).toEqual(expect.any(String));
expect(mockSaveMessage.mock.invocationCallOrder[0]).toBeLessThan(
mockStampForcedRetention.mock.invocationCallOrder[0],
);
diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js
index 01952cc88be..7a933fc4844 100644
--- a/api/server/controllers/agents/resume.js
+++ b/api/server/controllers/agents/resume.js
@@ -369,7 +369,11 @@ async function persistRePauseProgress({ req, client, job, streamId, conversation
}
await applyForcedRetention(
{ stampForcedRetention },
- { ctx: { userId, interfaceConfig: req.config?.interfaceConfig }, conversationId },
+ {
+ ctx: { userId, interfaceConfig: req.config?.interfaceConfig },
+ conversationId,
+ messageId: savedResponseMessage.messageId,
+ },
);
await recoverResumedResponseReference(
{ userId, conversationId, client, savedResponseMessage },
@@ -573,7 +577,11 @@ async function finalizeResumedTurn({
}
await applyForcedRetention(
{ stampForcedRetention },
- { ctx: { userId, interfaceConfig: req.config?.interfaceConfig }, conversationId },
+ {
+ ctx: { userId, interfaceConfig: req.config?.interfaceConfig },
+ conversationId,
+ messageId: savedResponseMessage.messageId,
+ },
);
await recoverResumedResponseReference(
{ userId, conversationId, client, savedResponseMessage },
From c1a2a5e6d3d8e996ad64817277625f4f82813be3 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 24 Sep 2026 20:03:10 +0200
Subject: [PATCH 10/20] fix: Clear Released Bookmark Tags When Forcing a Chat
Temporary
stampForcedRetention released a converted chat's bookmark counts but
kept its tags, so deleting that chat before TTL removal decremented the
same tags again and reduced counts owned by other visible chats. Clear
the tags in the conditional conversion update that releases them.
---
.../data-schemas/src/methods/conversation.spec.ts | 15 +++++++++++++++
packages/data-schemas/src/methods/conversation.ts | 7 +++++--
2 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts
index 23444330b9c..794082641d0 100644
--- a/packages/data-schemas/src/methods/conversation.spec.ts
+++ b/packages/data-schemas/src/methods/conversation.spec.ts
@@ -8953,6 +8953,21 @@ describe('stampForcedRetention', () => {
expect((await ConversationTag.findOne({ user: userId, tag: 'work' }).lean())?.count).toBe(0);
});
+ 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 });
diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts
index e27874cf764..715bf0c7870 100644
--- a/packages/data-schemas/src/methods/conversation.ts
+++ b/packages/data-schemas/src/methods/conversation.ts
@@ -2312,10 +2312,13 @@ export function createConversationMethods(
}
if (stored.isTemporary !== true) {
- /** Conditional on the transition, so concurrent stamps release the bookmark counts once. */
+ /**
+ * 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 } },
+ { $set: { isTemporary: true, expiredAt, tags: [] } },
{ timestamps: false },
);
if (converted.modifiedCount > 0 && stored.tags?.length) {
From b01043b9df81757ad9069eeab4a01965b8eae31f Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 24 Sep 2026 20:30:27 +0200
Subject: [PATCH 11/20] fix: Store No Bookmark Tags on Forced-Temporary Imports
An ephemeral import, fork or duplicate skipped its tag count increment
but kept the source tags, so deleting the copy before TTL removal
decremented counts owned by other visible chats. Store the same tags the
import counts, which is none for a forced-temporary copy.
---
api/server/utils/import/fork.spec.js | 3 ++-
api/server/utils/import/importBatchBuilder.js | 3 +++
e2e/specs/mock/scenarios/ephemeral-retention.spec.ts | 5 ++++-
packages/api/src/conversations/retention.ts | 5 +++--
4 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/api/server/utils/import/fork.spec.js b/api/server/utils/import/fork.spec.js
index b507f0f7fad..8ef4f8a9dd6 100644
--- a/api/server/utils/import/fork.spec.js
+++ b/api/server/utils/import/fork.spec.js
@@ -532,7 +532,7 @@ describe('duplicateConversation', () => {
expect(result.conversation.isTemporary).toBe(false);
});
- test('does not increment tag counts for forced-temporary duplicates', async () => {
+ test('neither counts nor stores tags on forced-temporary duplicates', async () => {
getConvo.mockResolvedValue({ ...mockConversation, tags: ['important', 'work'] });
await duplicateConversation({
@@ -542,6 +542,7 @@ describe('duplicateConversation', () => {
});
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 () => {
diff --git a/api/server/utils/import/importBatchBuilder.js b/api/server/utils/import/importBatchBuilder.js
index dc604c3480e..f7421fce57c 100644
--- a/api/server/utils/import/importBatchBuilder.js
+++ b/api/server/utils/import/importBatchBuilder.js
@@ -162,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;
diff --git a/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
index 17e2b7aca72..7180cb41065 100644
--- a/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
+++ b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
@@ -33,6 +33,7 @@ type StoredConversation = {
conversationId: string;
isTemporary?: boolean;
expiredAt?: Date | null;
+ tags?: string[];
};
type StoredMessage = StoredConversation & { messageId: string };
@@ -474,7 +475,9 @@ test.describe('ephemeral retention', () => {
expect(duplicatedId, 'the duplicate must identify its conversation').toBeTruthy();
cleanupConversationIds.push(duplicatedId as string);
- expectForcedTemporary(await readConversation(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 }),
diff --git a/packages/api/src/conversations/retention.ts b/packages/api/src/conversations/retention.ts
index eedba0798b3..2498ae5ff61 100644
--- a/packages/api/src/conversations/retention.ts
+++ b/packages/api/src/conversations/retention.ts
@@ -144,11 +144,12 @@ export function resolveImportRetentionFields(
}
/**
- * The bookmark tags an import should count.
+ * 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.
+ * 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,
From 8c32b61aa948a4cc56dcb4b3c6b43fff1bee5988 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Mon, 28 Sep 2026 09:21:23 +0200
Subject: [PATCH 12/20] test: Provide the Forced-Temporary Retention Helper to
the Header Stacking Mock
---
client/src/components/Chat/__tests__/Header.stacking.spec.tsx | 2 ++
1 file changed, 2 insertions(+)
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 }));
From a57d0ae8479eb8d4be5d56c25b5f1976fdff192d Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Tue, 29 Sep 2026 00:30:50 +0200
Subject: [PATCH 13/20] test: Reuse One Admin Login per Worker in Ephemeral
Retention Scenarios
---
e2e/specs/mock/scenarios/ephemeral-retention.spec.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
index 7180cb41065..5ee606a4392 100644
--- a/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
+++ b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
@@ -159,11 +159,14 @@ test.afterEach(async () => {
});
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 }) => {
- token = await loginAdmin(request);
+ workerToken ??= await loginAdmin(request);
+ token = workerToken;
userId = await resolveUserId();
});
From fe38ba35085ba335f03758ccdf8f6dc7e58482a5 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Tue, 29 Sep 2026 07:19:51 +0200
Subject: [PATCH 14/20] fix: Keep Legacy Temporary Chats Temporary When Forking
Under All-Data Retention
---
api/server/utils/import/fork.js | 11 ++++++++---
api/server/utils/import/fork.spec.js | 12 ++++++++++++
packages/api/src/conversations/retention.spec.ts | 13 +++++++++++++
packages/api/src/conversations/retention.ts | 8 ++++++++
4 files changed, 41 insertions(+), 3 deletions(-)
diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js
index 49e45d28e5c..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');
@@ -90,7 +95,7 @@ async function forkConversation({
legacyPii == null
? builderFactory(requestUserId, interfaceConfig, filters)
: builderFactory(requestUserId, interfaceConfig, filters, legacyPii);
- importBatchBuilder.sourceIsTemporary = originalConvo.isTemporary === true;
+ importBatchBuilder.sourceIsTemporary = isTemporaryRecord(originalConvo);
importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI);
let messagesToClone = [];
@@ -516,7 +521,7 @@ async function duplicateConversation({
legacyPii == null
? builderFactory(userId, interfaceConfig, filters)
: builderFactory(userId, interfaceConfig, filters, legacyPii);
- importBatchBuilder.sourceIsTemporary = originalConvo.isTemporary === true;
+ 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 8ef4f8a9dd6..2273ef2fc5c 100644
--- a/api/server/utils/import/fork.spec.js
+++ b/api/server/utils/import/fork.spec.js
@@ -520,6 +520,18 @@ describe('duplicateConversation', () => {
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 });
diff --git a/packages/api/src/conversations/retention.spec.ts b/packages/api/src/conversations/retention.spec.ts
index 604f598bef1..ff10b617428 100644
--- a/packages/api/src/conversations/retention.spec.ts
+++ b/packages/api/src/conversations/retention.spec.ts
@@ -7,6 +7,7 @@ import {
persistForcedTemporaryMetadata,
resolveImportRetentionFields,
resolveImportTagCounts,
+ isTemporaryRecord,
} from './retention';
describe('applyForcedRetention', () => {
@@ -312,3 +313,15 @@ describe('resolveImportTagCounts', () => {
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
index 2498ae5ff61..53878e54bfb 100644
--- a/packages/api/src/conversations/retention.ts
+++ b/packages/api/src/conversations/retention.ts
@@ -98,6 +98,14 @@ export function resolveResumableRetention(
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;
From 4f54dcec5f890c3151202188d162679bb718b2cf Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Tue, 29 Sep 2026 07:58:15 +0200
Subject: [PATCH 15/20] fix: Convert a Resumed Chat Under Ephemeral Retention
Before It Can Re-Pause Empty
---
.../agents/__tests__/resume.spec.js | 39 ++++++++++++++++---
api/server/controllers/agents/resume.js | 7 ++++
2 files changed, 41 insertions(+), 5 deletions(-)
diff --git a/api/server/controllers/agents/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js
index 7d8bbdd00a6..653c3c78b99 100644
--- a/api/server/controllers/agents/__tests__/resume.spec.js
+++ b/api/server/controllers/agents/__tests__/resume.spec.js
@@ -3284,20 +3284,49 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
{ conversationId: CONVO_ID, messageIds: [mockSaveMessage.mock.calls[0][1].messageId] },
);
expect(mockSaveMessage.mock.calls[0][1].messageId).toEqual(expect.any(String));
- expect(mockSaveMessage.mock.invocationCallOrder[0]).toBeLessThan(
- mockStampForcedRetention.mock.invocationCallOrder[0],
+ 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(mockStampForcedRetention.mock.invocationCallOrder[0]).toBeLessThan(
- publication.mock.invocationCallOrder[0],
- );
+ 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/resume.js b/api/server/controllers/agents/resume.js
index 7a933fc4844..c162b05216a 100644
--- a/api/server/controllers/agents/resume.js
+++ b/api/server/controllers/agents/resume.js
@@ -1915,6 +1915,13 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
{ 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;
}
From 7573fee3bbab94842c8bd62d7e59c52e8296f956 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Wed, 30 Sep 2026 15:41:53 +0200
Subject: [PATCH 16/20] fix: Treat a Reloaded Pre-Policy Run as Temporary Once
Retention Is Forced
---
.../SSE/__tests__/useResumeOnLoad.spec.tsx | 28 +++++++++++++++++++
client/src/hooks/SSE/useResumeOnLoad.ts | 9 ++++--
2 files changed, 35 insertions(+), 2 deletions(-)
diff --git a/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx b/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx
index 93f9523fb23..a5743eebd74 100644
--- a/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx
+++ b/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx
@@ -18,6 +18,7 @@ const mockUseStreamStatus = jest.fn();
const mockUseActiveJobs = jest.fn();
const mockUseAgentQueuedTurns = jest.fn();
const mockExtendActiveJobsGrace = jest.fn();
+let mockStartupConfig: { interface?: { retentionMode?: string } } | undefined;
let mockFileMap: Record = {};
jest.mock('~/Providers', () => ({
@@ -39,6 +40,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 }),
streamStatusQueryKey: (conversationId: string) => ['streamStatus', conversationId],
}));
@@ -424,6 +426,32 @@ describe('useResumeOnLoad', () => {
},
);
+ /** 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;
+ }
+ });
+
/** 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 17c64a1caa6..c4868805991 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,
@@ -305,6 +307,8 @@ export default function useResumeOnLoad(
const endpointType = currentConversation?.endpointType;
const actualEndpoint = endpointType ?? endpoint;
const resumableEnabled = !isAssistantsEndpoint(actualEndpoint);
+ const { data: startupConfig } = useGetStartupConfig();
+ const isRetentionForced = isForcedTemporaryRetention(startupConfig?.interface?.retentionMode);
// Track conversations we've already processed (either resumed or skipped)
const processedConvoRef = useRef(null);
/**
@@ -1057,7 +1061,7 @@ export default function useResumeOnLoad(
conversationId,
streamStatus.createdAt,
generationProtocolVersion,
- streamStatus.isTemporary === true,
+ streamStatus.isTemporary === true || isRetentionForced,
);
setSubmission(submission);
} else {
@@ -1075,7 +1079,7 @@ export default function useResumeOnLoad(
} as TMessage,
conversation: { conversationId, title: 'Resumed Chat' } as TConversation,
isRegenerate: false,
- isTemporary: streamStatus.isTemporary === true,
+ isTemporary: streamStatus.isTemporary === true || isRetentionForced,
endpointOption: {},
// Signal to useResumableSSE to subscribe to existing stream instead of starting new
resumeStreamId: streamStatus.streamId,
@@ -1114,6 +1118,7 @@ export default function useResumeOnLoad(
setActiveGenerationCreatedAt,
jotaiStore,
externalRunArm,
+ isRetentionForced,
]);
// Reset processedConvoRef when conversation changes to allow re-checking
From 246b142279fba67aaaeee12e300563ac62cfc96a Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Wed, 30 Sep 2026 15:42:06 +0200
Subject: [PATCH 17/20] fix: Stamp Messages with the Deadline a Concurrent
Retention Stamp Stored
---
.../src/methods/conversation.spec.ts | 27 +++++++++++++++++++
.../data-schemas/src/methods/conversation.ts | 12 ++++++++-
2 files changed, 38 insertions(+), 1 deletion(-)
diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts
index 794082641d0..99299f2d49e 100644
--- a/packages/data-schemas/src/methods/conversation.spec.ts
+++ b/packages/data-schemas/src/methods/conversation.spec.ts
@@ -8953,6 +8953,33 @@ describe('stampForcedRetention', () => {
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(),
diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts
index 715bf0c7870..1b556288d64 100644
--- a/packages/data-schemas/src/methods/conversation.ts
+++ b/packages/data-schemas/src/methods/conversation.ts
@@ -2311,6 +2311,7 @@ export function createConversationMethods(
}
}
+ let stampedDeadline = stored.expiredAt != null;
if (stored.isTemporary !== true) {
/**
* Conditional on the transition, so concurrent stamps release the bookmark counts once;
@@ -2321,20 +2322,29 @@ export function createConversationMethods(
{ $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) {
- await Conversation.updateOne(
+ 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 } },
From e9f932194ee037ab3f311601f6f5287147225838 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Wed, 30 Sep 2026 15:49:28 +0200
Subject: [PATCH 18/20] fix: Wait for the Startup Config Before Rebuilding a
Resumed Submission
---
.../SSE/__tests__/useResumeOnLoad.spec.tsx | 38 ++++++++++++++++++-
client/src/hooks/SSE/useResumeOnLoad.ts | 8 +++-
2 files changed, 44 insertions(+), 2 deletions(-)
diff --git a/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx b/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx
index a5743eebd74..1cdb74051e9 100644
--- a/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx
+++ b/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx
@@ -19,6 +19,7 @@ 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', () => ({
@@ -40,7 +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 }),
+ useGetStartupConfig: () => ({ data: mockStartupConfig, isFetched: mockStartupConfigSettled }),
streamStatusQueryKey: (conversationId: string) => ['streamStatus', conversationId],
}));
@@ -452,6 +453,41 @@ describe('useResumeOnLoad', () => {
}
});
+ /** 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 c4868805991..8ac28e09cb8 100644
--- a/client/src/hooks/SSE/useResumeOnLoad.ts
+++ b/client/src/hooks/SSE/useResumeOnLoad.ts
@@ -307,7 +307,7 @@ export default function useResumeOnLoad(
const endpointType = currentConversation?.endpointType;
const actualEndpoint = endpointType ?? endpoint;
const resumableEnabled = !isAssistantsEndpoint(actualEndpoint);
- const { data: startupConfig } = useGetStartupConfig();
+ 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);
@@ -834,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 &&
@@ -871,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) {
@@ -1119,6 +1124,7 @@ export default function useResumeOnLoad(
jotaiStore,
externalRunArm,
isRetentionForced,
+ startupConfigSettled,
]);
// Reset processedConvoRef when conversation changes to allow re-checking
From cf3504c34d967455ea61f30f47c3b1a492faaced Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Wed, 30 Sep 2026 16:26:08 +0200
Subject: [PATCH 19/20] test: Wait for the Resumed Response Row in the
Ephemeral Resume Scenario
---
e2e/specs/mock/scenarios/ephemeral-retention.spec.ts | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
index 5ee606a4392..a0e10b36682 100644
--- a/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
+++ b/e2e/specs/mock/scenarios/ephemeral-retention.spec.ts
@@ -265,14 +265,15 @@ test.describe('ephemeral retention', () => {
timeout: 30000,
});
- /** Streamed text can render before the resumed turn finishes its database writes. */
+ /** 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 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);
const history = await requestResult(request, { path: '/api/convos?limit=25', token });
expect(history.ok).toBe(true);
expect(history.text).not.toContain(conversationId);
From 6604d70fad4f3219e278b3babef26c4b4ef113f4 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Wed, 30 Sep 2026 16:40:37 +0200
Subject: [PATCH 20/20] test: Provide the Real Command Executor Stamp to the
Callbacks Mock
---
api/server/controllers/agents/__tests__/callbacks.spec.js | 1 +
1 file changed, 1 insertion(+)
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', () => ({