From 9c71570db18cd7154729288ee94216eb86d55165 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:22:18 +0200 Subject: [PATCH] fix: apply retention to forked and duplicated conversations The fork and duplicate paths created the import batch builder without the runtime interface config, so under retentionMode "ephemeral" a fork or duplicate of an existing permanent conversation skipped the forced isTemporary/expiredAt fields and bypassed the policy. Plumb req.config.interfaceConfig through forkConversation and duplicateConversation into the builder so cloned records honor retention. --- api/server/routes/convos.js | 2 ++ api/server/utils/import/fork.js | 11 ++++--- api/server/utils/import/fork.spec.js | 44 +++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index b38e73b576..b3ac97e203 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -359,6 +359,7 @@ router.post('/fork', forkIpLimiter, forkUserLimiter, async (req, res) => { records: true, splitAtTarget, option, + interfaceConfig: req.config?.interfaceConfig, }); res.json(result); @@ -376,6 +377,7 @@ router.post('/duplicate', forkIpLimiter, forkUserLimiter, async (req, res) => { userId: req.user.id, conversationId, title, + interfaceConfig: req.config?.interfaceConfig, }); res.status(201).json(result); } catch (error) { diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js index b0ba7c0210..b282bea2c8 100644 --- a/api/server/utils/import/fork.js +++ b/api/server/utils/import/fork.js @@ -81,7 +81,8 @@ function cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder) { * @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 {(userId: string) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance. + * @param {(userId: string, interfaceConfig?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance. + * @param {object} [params.interfaceConfig] - Runtime interface config used to apply retention to cloned records. * @returns {Promise} The response after forking the conversation. */ async function forkConversation({ @@ -94,6 +95,7 @@ async function forkConversation({ splitAtTarget = false, latestMessageId, builderFactory = createImportBatchBuilder, + interfaceConfig, }) { try { const originalConvo = await getConvo(requestUserId, originalConvoId); @@ -110,7 +112,7 @@ async function forkConversation({ targetMessageId = latestMessageId; } - const importBatchBuilder = builderFactory(requestUserId); + const importBatchBuilder = builderFactory(requestUserId, interfaceConfig); importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI); let messagesToClone = []; @@ -500,9 +502,10 @@ 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. * @returns {Promise<{ conversation: TConversation, messages: TMessage[] }>} The duplicated conversation and messages. */ -async function duplicateConversation({ userId, conversationId, title }) { +async function duplicateConversation({ userId, conversationId, title, interfaceConfig }) { const originalConvo = await getConvo(userId, conversationId); if (!originalConvo) { throw new Error('Conversation not found'); @@ -518,7 +521,7 @@ async function duplicateConversation({ userId, conversationId, title }) { originalMessages[originalMessages.length - 1].messageId, ); - const importBatchBuilder = createImportBatchBuilder(userId); + const importBatchBuilder = createImportBatchBuilder(userId, interfaceConfig); 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 adb576d0cb..364c39f78b 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'); jest.mock('~/models', () => ({ getConvo: jest.fn(), @@ -107,6 +107,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', @@ -264,6 +286,26 @@ 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('should duplicate conversation and increment tag counts', async () => { const mockConvoWithTags = { ...mockConversation,