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.
This commit is contained in:
Marco Beretta 2026-06-17 15:22:18 +02:00
parent 06f462ded6
commit 9c71570db1
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
3 changed files with 52 additions and 5 deletions

View file

@ -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) {

View file

@ -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<TForkConvoResponse>} 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);

View file

@ -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,