diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index aabfffa651..69c5b811c2 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -899,6 +899,33 @@ describe('Message Operations', () => { } }); + it('caps a message to a sooner parent expiry instead of orphaning it', async () => { + const conversationId = uuidv4(); + const parentExpiry = new Date(Date.now() + 60 * 60 * 1000); + await Conversation().create({ + conversationId, + user: 'user123', + endpoint: 'openAI', + title: 'Ephemeral chat expiring soon', + isTemporary: true, + expiredAt: parentExpiry, + }); + + const saved = await saveMessage( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' }, + ); + + expect(saved?.expiredAt?.getTime()).toBe(parentExpiry.getTime()); + + const convo = await Conversation().findOne({ conversationId }).lean(); + expect(convo?.expiredAt?.getTime()).toBe(parentExpiry.getTime()); + expect(saved?.expiredAt?.getTime()).toBeLessThanOrEqual(convo?.expiredAt?.getTime() ?? 0); + }); + it('does not touch the parent conversation outside forced retention', async () => { const conversationId = uuidv4(); await Conversation().create({ diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 4d1c818ba1..08da85ac27 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -2,6 +2,7 @@ import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provid import type { DeleteResult, FilterQuery, Model, PipelineStage } from 'mongoose'; import type { AppConfig, IConversation, IMessage } from '~/types'; import { + conversationNeedsForcedRetention, createFallbackRetentionDate, forceConversationMessagesTemporary, forcedRetentionGapFilter, @@ -164,6 +165,25 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa logger.info(`---\`saveMessage\` context: ${metadata?.context}`); update.tokenCount = 0; } + + const forcedExpiredAt = update.expiredAt; + const isForcedRetention = isForcedTemporaryRetention(interfaceConfig?.retentionMode); + let parentRetention: { isTemporary?: boolean | null; expiredAt?: Date | null } | null = null; + if (isForcedRetention && forcedExpiredAt instanceof Date) { + const Conversation = mongoose.models.Conversation as Model; + parentRetention = await Conversation.findOne( + { conversationId, user: userId }, + 'isTemporary expiredAt', + ).lean<{ isTemporary?: boolean | null; expiredAt?: Date | null } | null>(); + if ( + parentRetention?.isTemporary === true && + parentRetention.expiredAt != null && + parentRetention.expiredAt.getTime() < forcedExpiredAt.getTime() + ) { + update.expiredAt = parentRetention.expiredAt; + } + } + const message = await Message.findOneAndUpdate( { messageId: params.messageId, user: userId }, update, @@ -183,10 +203,10 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa message.isTemporary = false; } - const forcedExpiredAt = update.expiredAt; if ( - isForcedTemporaryRetention(interfaceConfig?.retentionMode) && - forcedExpiredAt instanceof Date + isForcedRetention && + forcedExpiredAt instanceof Date && + conversationNeedsForcedRetention(parentRetention, forcedExpiredAt) ) { const Conversation = mongoose.models.Conversation as Model; const convoResult = await Conversation.updateOne(