diff --git a/api/server/controllers/agents/__tests__/callbacks.spec.js b/api/server/controllers/agents/__tests__/callbacks.spec.js index 6423c6b4ce1..2ffcc79952d 100644 --- a/api/server/controllers/agents/__tests__/callbacks.spec.js +++ b/api/server/controllers/agents/__tests__/callbacks.spec.js @@ -33,6 +33,7 @@ jest.mock('@librechat/api', () => ({ isCodeArtifactToolOutput: jest.requireActual('@librechat/api').isCodeArtifactToolOutput, isCodeSessionToolName: jest.requireActual('@librechat/api').isCodeSessionToolName, collectToolCallIds: jest.requireActual('@librechat/api').collectToolCallIds, + stampCommandExecutor: jest.requireActual('@librechat/api').stampCommandExecutor, })); jest.mock('@librechat/data-schemas', () => ({ diff --git a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js index b725dd45cf8..05f4e55b7d2 100644 --- a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js +++ b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js @@ -49,6 +49,8 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('@librechat/api', () => ({ getAgentErrorMetadata: (...args) => jest.requireActual('@librechat/api').getAgentErrorMetadata(...args), + applyForcedTemporaryRequest: jest.fn(), + resolveResumableRetention: jest.requireActual('@librechat/api').resolveResumableRetention, sendEvent: jest.fn(), persistedReasoningOverrideFields: jest.requireActual('@librechat/api').persistedReasoningOverrideFields, diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index 0981b5fa629..f4090c67661 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -252,6 +252,7 @@ const mockCleanupMCPRequestContextForReq = jest.fn(async (req) => { jest.mock('@librechat/data-schemas', () => ({ logger: mockLogger, + createChatExpirationDate: jest.requireActual('@librechat/data-schemas').createChatExpirationDate, })); jest.mock('@librechat/api', () => ({ @@ -260,6 +261,8 @@ jest.mock('@librechat/api', () => ({ ).getSteerRecoveryFailure, getAgentErrorMetadata: (...args) => jest.requireActual('@librechat/api').getAgentErrorMetadata(...args), + applyForcedTemporaryRequest: jest.fn(), + resolveResumableRetention: jest.requireActual('@librechat/api').resolveResumableRetention, sendEvent: jest.fn(), /** Real, because whether a skipped-persistence turn may raise an indicator is under test. */ isAnnounceableReply: jest.requireActual('@librechat/api').isAnnounceableReply, @@ -1011,6 +1014,39 @@ describe('ResumableAgentController resume metadata', () => { expect(mockStartupTelemetry.end).toHaveBeenCalledWith('error', expect.any(Error)); }); + it('records a forced-temporary run as temporary, with a deadline, whatever the client sent', async () => { + const conversationId = 'conversation-ephemeral'; + const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading')); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Hello', + messageId: 'user-message', + parentMessageId: 'parent-message', + conversationId, + isTemporary: false, + endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-3.5-turbo' } }, + }, + config: { interfaceConfig: { retentionMode: 'ephemeral', temporaryChatRetention: 1 } }, + }; + const res = { + headersSent: true, + json: jest.fn(() => { + res.headersSent = true; + }), + status: jest.fn(() => res), + }; + + await AgentController(req, res, jest.fn(), initializeClient, null); + + expect(require('@librechat/api').applyForcedTemporaryRequest).toHaveBeenCalledWith(req); + const [, , , options] = mockGenerationJobManager.createJob.mock.calls.at(-1); + expect(options.initialMetadata.isTemporary).toBe(true); + expect(new Date(options.initialMetadata.retentionExpiresAt).getTime()).toBeGreaterThan( + Date.now(), + ); + }); + it('persists and exactly echoes protocol v2 on a newly created generation', async () => { mockGenerationJobManager.createJob.mockResolvedValue({ createdAt: 1000, diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index 15bcdfa0b6f..aa079c64b46 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -200,6 +200,9 @@ jest.mock('@librechat/agents', () => ({ jest.mock('@librechat/api', () => ({ getAgentErrorMetadata: (...args) => jest.requireActual('@librechat/api').getAgentErrorMetadata(...args), + getConversationWriteContext: (...args) => + jest.requireActual('@librechat/api').getConversationWriteContext(...args), + announceReply: jest.fn().mockResolvedValue(undefined), /* Provisioning moved into this package; the controllers build the callback from it. */ createProvisionFilesCallback: () => async () => {}, createAgentExecutionContext: (context) => context, @@ -1040,7 +1043,7 @@ describe('createResponse controller', () => { expect(api.getLangfuseTraceMessageFields).toHaveBeenCalledWith(req.config, 'resp_mock-123'); expect(saveMessage).toHaveBeenCalledWith( - req, + expect.objectContaining({ userId: 'user-123' }), expect.objectContaining({ messageId: 'resp_mock-123', isCreatedByUser: false, @@ -1052,6 +1055,41 @@ describe('createResponse controller', () => { ); }); + it.each([false, true])( + 'stores input and output under the retention write context: stream=%s', + async (stream) => { + const api = require('@librechat/api'); + const db = require('~/models'); + req.config.interfaceConfig = { retentionMode: 'ephemeral', temporaryChatRetention: 1 }; + req.body.isTemporary = false; + api.validateResponseRequest.mockReturnValueOnce({ + request: { ...req.body, stream, store: true }, + }); + api.convertInputToMessages.mockReturnValueOnce([ + { role: 'user', content: 'Hello', messageId: 'input-123' }, + ]); + const savedResponse = { messageId: 'resp_mock-123', isTemporary: true }; + db.saveMessage.mockResolvedValueOnce({ messageId: 'input-123' }); + db.saveMessage.mockResolvedValueOnce(savedResponse); + + await createResponse(req, res); + + expect(db.saveMessage).toHaveBeenCalledTimes(2); + for (const [context] of db.saveMessage.mock.calls) { + expect(context).toEqual({ + userId: 'user-123', + isTemporary: false, + expiredAt: undefined, + interfaceConfig: req.config.interfaceConfig, + }); + } + expect(api.announceReply).toHaveBeenCalledWith( + db, + expect.objectContaining({ reply: savedResponse }), + ); + }, + ); + describe('execution envelope', () => { it('creates the portable run input before agent initialization', async () => { req.user = { diff --git a/api/server/controllers/agents/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js index cf0ebef3f14..653c3c78b99 100644 --- a/api/server/controllers/agents/__tests__/resume.spec.js +++ b/api/server/controllers/agents/__tests__/resume.spec.js @@ -96,6 +96,7 @@ const mockCheckAccess = jest.fn(); const mockCheckPermission = jest.fn(); const mockDecryptMetadata = jest.fn(); const mockStampConvoLastResponse = jest.fn().mockResolvedValue(undefined); +const mockStampForcedRetention = jest.fn().mockResolvedValue(undefined); const mockDisposeClient = jest.fn(); const mockGetMCPRequestContext = jest.fn(); const mockCleanupMCPRequestContextForReq = jest.fn(); @@ -152,6 +153,7 @@ jest.mock('@librechat/api', () => ({ jest.mock('~/models', () => ({ saveMessage: (...args) => mockSaveMessage(...args), + stampForcedRetention: (...args) => mockStampForcedRetention(...args), getConvo: (...args) => mockGetConvo(...args), getChatProject: (...args) => mockGetChatProject(...args), getMessages: (...args) => mockGetMessages(...args), @@ -3237,6 +3239,94 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => { expect(capturedInit.files).toEqual([{ file_id: 'f1' }]); }); + it.each([false, true])( + 'converts a pre-policy paused chat when re-pause=%s', + async (rePause) => { + requestConfigOverrides.interfaceConfig = { + retentionMode: 'ephemeral', + temporaryChatRetention: 1, + }; + mockGenerationJobManager.getJob.mockResolvedValue( + makeToolApprovalJob({ metadata: { isTemporary: false } }), + ); + mockSaveMessage.mockImplementation(async (_ctx, message) => message); + if (rePause) { + mockInitializeClient.mockResolvedValue({ + client: makeClient({ + pendingApproval: { actionId: NEXT_ACTION_ID }, + contentParts: [{ type: 'text', text: 'partial' }], + }), + userMCPAuthMap: {}, + }); + } + + const res = await post(approveBody({ isTemporary: false })); + expect(res.status).toBe(200); + await settled; + await flush(); + + expect(mockInitializeClient.mock.calls[0][0].req.body.isTemporary).toBe(true); + expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith( + CONVO_ID, + { isTemporary: true }, + 1000, + ); + expect(mockGenerationJobManager.updateMetadata.mock.invocationCallOrder[0]).toBeLessThan( + mockInitializeClient.mock.invocationCallOrder[0], + ); + expect(mockSaveMessage).toHaveBeenCalledWith( + expect.objectContaining({ isTemporary: true }), + expect.anything(), + expect.anything(), + ); + expect(mockStampForcedRetention).toHaveBeenCalledWith( + { userId: USER_ID, interfaceConfig: requestConfigOverrides.interfaceConfig }, + { conversationId: CONVO_ID, messageIds: [mockSaveMessage.mock.calls[0][1].messageId] }, + ); + expect(mockSaveMessage.mock.calls[0][1].messageId).toEqual(expect.any(String)); + expect(mockStampForcedRetention.mock.calls[0][1]).toEqual({ + conversationId: CONVO_ID, + messageIds: [], + }); + expect(mockStampForcedRetention.mock.invocationCallOrder[0]).toBeLessThan( + mockInitializeClient.mock.invocationCallOrder[0], + ); + const messageStampOrder = mockStampForcedRetention.mock.invocationCallOrder.at(-1); + expect(mockSaveMessage.mock.invocationCallOrder[0]).toBeLessThan(messageStampOrder); + const publication = rePause + ? mockGenerationJobManager.approvals.finishPausePersistence + : mockGenerationJobManager.publishTerminalClaim; + expect(messageStampOrder).toBeLessThan(publication.mock.invocationCallOrder[0]); + expect(mockStampConvoLastResponse).not.toHaveBeenCalled(); + expect(mockAddTitle).not.toHaveBeenCalled(); + }, + ); + + it('converts a pre-policy paused chat that re-pauses without new output', async () => { + requestConfigOverrides.interfaceConfig = { + retentionMode: 'ephemeral', + temporaryChatRetention: 1, + }; + mockGenerationJobManager.getJob.mockResolvedValue( + makeToolApprovalJob({ metadata: { isTemporary: false } }), + ); + mockInitializeClient.mockResolvedValue({ + client: makeClient({ pendingApproval: { actionId: NEXT_ACTION_ID }, contentParts: [] }), + userMCPAuthMap: {}, + }); + + const res = await post(approveBody({ isTemporary: false })); + expect(res.status).toBe(200); + await settled; + await flush(); + + expect(mockSaveMessage).not.toHaveBeenCalled(); + expect(mockStampForcedRetention).toHaveBeenCalledWith( + { userId: USER_ID, interfaceConfig: requestConfigOverrides.interfaceConfig }, + { conversationId: CONVO_ID, messageIds: [] }, + ); + }); + it.each([false, true])('preserves the job deadline when re-pause=%s', async (rePause) => { const expiredAt = new Date('2030-01-01T00:00:00.000Z'); mockGenerationJobManager.getJob.mockResolvedValue( diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index fa656d53ae3..04f1ec40a06 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -11,6 +11,8 @@ const { toPendingSteer, persistedReasoningOverrideFields, getViolationInfo, + applyForcedTemporaryRequest, + resolveResumableRetention, buildMessageFiles, getReferencedQuotes, resolveTitleTiming, @@ -748,6 +750,7 @@ function rejectMissingTriggerParentMessageId(res, generationProtocolVersion) { * Returns streamId immediately, client subscribes separately via SSE. */ const ResumableAgentController = async (req, res, next, initializeClient, addTitle) => { + applyForcedTemporaryRequest(req); const startupTelemetry = getAgentStartupTelemetry(req); let generationProtocolVersion = negotiateNewGenerationProtocol(req); const { @@ -1679,24 +1682,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit agent_id: endpointOption.agent_id ?? req.body?.agent_id, // Persist temporary-chat state so a HITL resume keeps the resumed response // non-persisted instead of trusting the resume request to re-send the flag. - isTemporary: - req._agentEventBindingRetention?.isTemporary ?? - req.resolvedConversation?.isTemporary ?? - req.body?.isTemporary, - ...((req._agentEventBindingRetention?.expiredAt ?? req.resolvedConversation?.expiredAt) != - null && { - retentionExpiresAt: new Date( - req._agentEventBindingRetention?.expiredAt ?? req.resolvedConversation.expiredAt, - ).toISOString(), - }), - ...((req._agentEventBindingRetention?.expiredAt ?? req.resolvedConversation?.expiredAt) == - null && - req.config?.interfaceConfig?.retentionMode === 'all' && { - retentionExpiresAt: createChatExpirationDate( - req.config.interfaceConfig, - req.resolvedConversation?.isTemporary ?? req.body?.isTemporary, - ).toISOString(), - }), + ...resolveResumableRetention(req, createChatExpirationDate), ...(agentEventDelivery != null && { agentEventDeliveryKey: agentEventDelivery.deliveryKey, ...(internalDetachedCompletion == null diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 13917a58a11..7daf8478b84 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -90,6 +90,7 @@ const { resolvePersistableCodeEnvironmentDecision, createTerminalRunErrorObserver, announceReply, + getConversationWriteContext, } = require('@librechat/api'); const { createResponsesToolEndCallback, @@ -375,7 +376,7 @@ async function saveInputMessages(req, conversationId, inputMessages, agentId) { for (const msg of inputMessages) { if (msg.role === 'user') { await db.saveMessage( - req, + getConversationWriteContext(req), { messageId: msg.messageId || nanoid(), conversationId, @@ -426,7 +427,7 @@ async function saveResponseOutput( // Save the assistant message return db.saveMessage( - req, + getConversationWriteContext(req), { messageId: responseId, conversationId, @@ -456,12 +457,7 @@ async function saveResponseOutput( async function saveConversation(req, conversationId, agentId, agent, codeEnvironmentDecision) { const title = resolveConversationTitle(req, agent?.name || 'Open Responses Conversation'); await db.saveConvo( - { - userId: req?.user?.id, - isTemporary: req?.resolvedConversation?.isTemporary ?? req?.body?.isTemporary, - expiredAt: req?.resolvedConversation?.expiredAt, - interfaceConfig: req?.config?.interfaceConfig, - }, + getConversationWriteContext(req), { conversationId, endpoint: EModelEndpoint.agents, @@ -1453,10 +1449,7 @@ const executeResponse = async (envelope, { req, res }) => { await announceReply(db, { userId: req?.user?.id, conversationId, - reply: { - ...savedResponse, - isTemporary: req?.resolvedConversation?.isTemporary ?? req?.body?.isTemporary, - }, + reply: savedResponse, context: 'Responses API - announce stored reply', }); @@ -1696,10 +1689,7 @@ const executeResponse = async (envelope, { req, res }) => { await announceReply(db, { userId: req?.user?.id, conversationId, - reply: { - ...savedResponse, - isTemporary: req?.resolvedConversation?.isTemporary ?? req?.body?.isTemporary, - }, + reply: savedResponse, context: 'Responses API - announce stored reply', }); diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js index 3cffe9b151b..c162b05216a 100644 --- a/api/server/controllers/agents/resume.js +++ b/api/server/controllers/agents/resume.js @@ -52,6 +52,9 @@ const { PROJECT_CONTEXT_CHANGED_RESPONSE, restoreScheduledTokenContext, recoverTurnMessageReference, + applyForcedRetention, + applyForcedTemporaryRequest, + persistForcedTemporaryMetadata, announceReply, } = require('@librechat/api'); const { disposeClient } = require('~/server/cleanup'); @@ -86,6 +89,7 @@ const { settleAgentEventActorDetachedAction, appendConvoMessageReference, stampConvoLastResponse, + stampForcedRetention, } = require('~/models'); const { acquireEventChildGenerationLease, @@ -363,6 +367,14 @@ async function persistRePauseProgress({ req, client, job, streamId, conversation if (!savedResponseMessage) { throw new Error('Re-pause response progress could not be persisted'); } + await applyForcedRetention( + { stampForcedRetention }, + { + ctx: { userId, interfaceConfig: req.config?.interfaceConfig }, + conversationId, + messageId: savedResponseMessage.messageId, + }, + ); await recoverResumedResponseReference( { userId, conversationId, client, savedResponseMessage }, 'api/server/controllers/agents/resume.js - recovered re-paused response reference', @@ -563,6 +575,14 @@ async function finalizeResumedTurn({ if (!savedResponseMessage) { throw new Error('Resumed response could not be persisted before terminal publication'); } + await applyForcedRetention( + { stampForcedRetention }, + { + ctx: { userId, interfaceConfig: req.config?.interfaceConfig }, + conversationId, + messageId: savedResponseMessage.messageId, + }, + ); await recoverResumedResponseReference( { userId, conversationId, client, savedResponseMessage }, 'api/server/controllers/agents/resume.js - recovered resumed response reference', @@ -1050,6 +1070,7 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle) // Rebuild the same persistence/retention mode as the paused turn. The resume body // is not authoritative: tools inspect this field during client initialization. req.body.isTemporary = job.metadata.isTemporary === true; + applyForcedTemporaryRequest(req, job.metadata); const metaFiles = job.metadata.userMessage?.files; if (Array.isArray(metaFiles) && metaFiles.length > 0) { req.body.files = metaFiles; @@ -1889,6 +1910,18 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle) req.turnStartedAt = job.createdAt; + await persistForcedTemporaryMetadata( + req, + { streamId, createdAt: job.createdAt }, + GenerationJobManager, + ); + await applyForcedRetention( + { stampForcedRetention }, + { + ctx: { userId: req.user.id, interfaceConfig: req.config?.interfaceConfig }, + conversationId, + }, + ); if (userSubmittedPaths.length > 0) { job.metadata.userSubmittedPaths = userSubmittedPaths; } diff --git a/api/server/controllers/assistants/chat.contentFilter.spec.js b/api/server/controllers/assistants/chat.contentFilter.spec.js index 2b740837321..3dd07762603 100644 --- a/api/server/controllers/assistants/chat.contentFilter.spec.js +++ b/api/server/controllers/assistants/chat.contentFilter.spec.js @@ -280,6 +280,21 @@ describe.each([ expect(mockHandleError).not.toHaveBeenCalled(); expect(mockSendResponse).not.toHaveBeenCalled(); } + + it.each([false, undefined])( + 'applies forced temporary retention before assistant initialization (%s)', + async (isTemporary) => { + req.body.isTemporary = isTemporary; + req.config.interfaceConfig = { retentionMode: 'ephemeral', temporaryChatRetention: 1 }; + mockInitThread.mockRejectedValueOnce(new Error('stop after retention setup')); + + await chatController(req, res); + + expect(mockInitThread).toHaveBeenCalledTimes(1); + expect(req.body.isTemporary).toBe(true); + }, + ); + it('persists the assistant before FINAL and forwards the settled read-state stamp', async () => { const stamp = new Date('2026-09-08T12:00:00.000Z'); const settledConversation = { diff --git a/api/server/controllers/assistants/chatV1.js b/api/server/controllers/assistants/chatV1.js index 95f0d66e2c3..5d3fa97e3d2 100644 --- a/api/server/controllers/assistants/chatV1.js +++ b/api/server/controllers/assistants/chatV1.js @@ -19,6 +19,7 @@ const { settleAssistantFinal, resolveAssistantProjectTurn, joinChatProjectInstructions, + applyForcedTemporaryRequest, } = require('@librechat/api'); const { Time, @@ -74,6 +75,7 @@ const { getOpenAIClient } = require('./helpers'); * @returns {void} */ const chatV1 = async (req, res) => { + applyForcedTemporaryRequest(req); const appConfig = req.config; const { diff --git a/api/server/controllers/assistants/chatV2.js b/api/server/controllers/assistants/chatV2.js index 992dbfdfa9b..c0f3018c763 100644 --- a/api/server/controllers/assistants/chatV2.js +++ b/api/server/controllers/assistants/chatV2.js @@ -18,6 +18,7 @@ const { settleAssistantFinal, resolveAssistantProjectTurn, joinChatProjectInstructions, + applyForcedTemporaryRequest, } = require('@librechat/api'); const { Time, @@ -67,6 +68,7 @@ const { getOpenAIClient } = require('./helpers'); * @returns {void} */ const chatV2 = async (req, res) => { + applyForcedTemporaryRequest(req); const appConfig = req.config; /** @type {{files: MongoFile[]}} */ diff --git a/api/server/middleware/optionalShareFileAuth.spec.js b/api/server/middleware/optionalShareFileAuth.spec.js index ffc0cf5cfc9..f68a356abf9 100644 --- a/api/server/middleware/optionalShareFileAuth.spec.js +++ b/api/server/middleware/optionalShareFileAuth.spec.js @@ -4,8 +4,9 @@ const mockFindSession = jest.fn(); const mockRunAsSystem = jest.fn((fn) => fn()); jest.mock('jsonwebtoken', () => ({ verify: (...args) => mockVerify(...args) })); +/** Installed packages need real module IDs when Jest reuses a resolver across suites. */ jest.mock('@librechat/api', () => ({ isEnabled: (v) => v === 'true' || v === true }), { - virtual: true, + virtual: false, }); jest.mock( '@librechat/data-schemas', @@ -13,10 +14,10 @@ jest.mock( logger: { warn: jest.fn(), error: jest.fn() }, runAsSystem: (...args) => mockRunAsSystem(...args), }), - { virtual: true }, + { virtual: false }, ); jest.mock('librechat-data-provider', () => ({ SystemRoles: { USER: 'USER' } }), { - virtual: true, + virtual: false, }); jest.mock('~/models', () => ({ getUserById: (...args) => mockGetUserById(...args), diff --git a/api/server/routes/__tests__/messages-content-edit.spec.js b/api/server/routes/__tests__/messages-content-edit.spec.js index 9ed509aa45c..29d98761aa6 100644 --- a/api/server/routes/__tests__/messages-content-edit.spec.js +++ b/api/server/routes/__tests__/messages-content-edit.spec.js @@ -22,6 +22,7 @@ jest.mock('@librechat/api', () => ({ CHILD_THREAD_READ_ONLY_ERROR: 'Child thread is view-only.', isSubagentThreadWriteBlocked: jest.fn().mockResolvedValue(false), requireFeedbackEnabled: (req, res, next) => next(), + applyForcedRetention: jest.fn(), })); jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({})); @@ -37,6 +38,8 @@ jest.mock('@librechat/data-schemas', () => ({ })); jest.mock('~/models', () => ({ + saveConvo: jest.fn(), + saveMessage: jest.fn(), getMessages: jest.fn(), updateMessage: jest.fn(), })); @@ -56,7 +59,7 @@ jest.mock('~/server/middleware', () => ({ describe('PUT /:conversationId/:messageId content edit', () => { let app; - const { getMessages, updateMessage } = require('~/models'); + const { getMessages, saveConvo, saveMessage, updateMessage } = require('~/models'); const { assertStoredMessageMutationAllowed } = require('@librechat/api'); beforeAll(() => { @@ -73,6 +76,8 @@ describe('PUT /:conversationId/:messageId content edit', () => { beforeEach(() => { jest.clearAllMocks(); updateMessage.mockResolvedValue({ messageId: 'message-1' }); + saveMessage.mockResolvedValue({ messageId: 'message-1', conversationId: 'conversation-1' }); + saveConvo.mockResolvedValue({ conversationId: 'conversation-1' }); }); it('preserves content-part metadata when editing its text', async () => { diff --git a/api/server/routes/__tests__/messages-feedback.spec.js b/api/server/routes/__tests__/messages-feedback.spec.js index 46d53a3ce06..ca38a895ef5 100644 --- a/api/server/routes/__tests__/messages-feedback.spec.js +++ b/api/server/routes/__tests__/messages-feedback.spec.js @@ -14,6 +14,7 @@ jest.mock('@librechat/api', () => ({ CHILD_THREAD_READ_ONLY_ERROR: 'Child thread is view-only.', isSubagentThreadWriteBlocked: jest.fn().mockResolvedValue(false), requireFeedbackEnabled: jest.fn((req, res, next) => next()), + applyForcedRetention: jest.fn(), })); jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({})); diff --git a/api/server/routes/__tests__/messages-get.spec.js b/api/server/routes/__tests__/messages-get.spec.js index b72c0973f48..10620582326 100644 --- a/api/server/routes/__tests__/messages-get.spec.js +++ b/api/server/routes/__tests__/messages-get.spec.js @@ -59,6 +59,7 @@ jest.mock('@librechat/api', () => { extractChatContent, extractFeedbackContent: jest.fn(() => []), extractStoredMessageContent, + applyForcedRetention: jest.fn(), contentFilterBlockResponse, getContentTraversalFragments, isContentTraversalLimitError, diff --git a/api/server/routes/__tests__/messages-retention.spec.js b/api/server/routes/__tests__/messages-retention.spec.js new file mode 100644 index 00000000000..fc796580549 --- /dev/null +++ b/api/server/routes/__tests__/messages-retention.spec.js @@ -0,0 +1,237 @@ +const express = require('express'); +const request = require('supertest'); +const { RetentionMode } = require('librechat-data-provider'); + +jest.mock('@librechat/agents', () => ({ + sleep: jest.fn(), +})); + +jest.mock('@librechat/api', () => ({ + unescapeLaTeX: jest.fn((value) => value), + countTokens: jest.fn().mockResolvedValue(10), + createContentFilter: jest.fn(() => (_req, _res, next) => next()), + sendFeedbackScore: jest.fn().mockResolvedValue(undefined), + traceIdForMessage: jest.fn((messageId) => `trace-${messageId}`), + mergeQuotedTextForCount: jest.fn((text) => text), + assertStoredMessageMutationAllowed: jest.fn(), + assertChatMutationAllowed: jest.fn(), + assertStoredMessageBranchAllowed: jest.fn(), + mergeUserSubmittedPaths: (...lists) => [...new Set(lists.flat().filter(Boolean))], + mergeUserSubmittedMessageFieldPaths: (...lists) => lists.flat().filter(Boolean), + isContentFilterError: jest.fn(() => false), + CHILD_THREAD_READ_ONLY_ERROR: 'Child thread is view-only.', + isSubagentThreadWriteBlocked: jest.fn().mockResolvedValue(false), + requireFeedbackEnabled: (req, res, next) => next(), + applyForcedRetention: jest.fn(), +})); + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({})); + +jest.mock('~/models', () => ({ + stampForcedRetention: jest.fn(), + saveConvo: jest.fn(), + getConvo: jest.fn(), + getMessage: jest.fn(), + saveMessage: jest.fn(), + getMessages: jest.fn(), + getFiles: jest.fn().mockResolvedValue([]), + updateMessage: jest.fn(), + deleteMessages: jest.fn(), +})); + +jest.mock('~/server/services/Artifacts/update', () => ({ + findAllArtifacts: jest.fn(), + replaceArtifactContent: jest.fn(), +})); + +jest.mock('~/server/middleware', () => ({ + requireJwtAuth: (req, res, next) => next(), + validateMessageReq: (req, res, next) => next(), + configMiddleware: jest.fn((req, res, next) => next()), + sendValidationResponse: jest.fn(), + canReadActiveJobConversation: jest.fn().mockResolvedValue(false), + prepareMessageRequestValidation: jest.fn(), +})); + +/** + * Each of these routes writes a message without going through the conversation, so under + * forced retention the chat holding it has to be re-stamped too. Whether the helper writes + * is decided by the retention mode and covered in + * `packages/api/src/conversations/retention.spec.ts`. + */ +describe('message writes that bypass conversation retention', () => { + let app; + const { configMiddleware } = require('~/server/middleware'); + const { getMessage, saveMessage, stampForcedRetention } = require('~/models'); + const { applyForcedRetention } = require('@librechat/api'); + const { + findAllArtifacts, + replaceArtifactContent, + } = require('~/server/services/Artifacts/update'); + + const userId = 'user-1'; + const conversationId = 'conversation-1'; + const messageId = 'message-1'; + const interfaceConfig = { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 }; + + beforeAll(() => { + const messagesRouter = require('../messages'); + + app = express(); + app.use(express.json()); + app.use((req, res, next) => { + req.user = { id: userId }; + req.config = { interfaceConfig }; + next(); + }); + app.use('/api/messages', messagesRouter); + }); + + beforeEach(() => { + jest.clearAllMocks(); + configMiddleware.mockImplementation((req, res, next) => next()); + }); + + describe('POST /artifact/:messageId', () => { + beforeEach(() => { + getMessage.mockResolvedValue({ + messageId, + conversationId, + text: 'original artifact', + content: undefined, + }); + findAllArtifacts.mockReturnValue([{ source: 'text', partIndex: 0 }]); + replaceArtifactContent.mockReturnValue('updated artifact'); + saveMessage.mockResolvedValue({ messageId, conversationId, text: 'updated artifact' }); + }); + + it('re-stamps the parent conversation of the edited message', async () => { + const response = await request(app) + .post(`/api/messages/artifact/${messageId}`) + .send({ index: 0, original: 'original artifact', updated: 'updated artifact' }); + + expect(response.status).toBe(200); + expect(saveMessage).toHaveBeenCalledWith( + expect.objectContaining({ userId, interfaceConfig }), + expect.objectContaining({ messageId, conversationId, user: userId }), + { context: 'POST /api/messages/artifact/:messageId' }, + ); + expect(applyForcedRetention).toHaveBeenCalledWith( + { stampForcedRetention }, + { + ctx: expect.objectContaining({ userId, interfaceConfig }), + conversationId, + }, + ); + }); + + it('does not touch the conversation when the artifact edit fails to match', async () => { + replaceArtifactContent.mockReturnValue(null); + + const response = await request(app) + .post(`/api/messages/artifact/${messageId}`) + .send({ index: 0, original: 'missing', updated: 'updated artifact' }); + + expect(response.status).toBe(400); + expect(saveMessage).not.toHaveBeenCalled(); + expect(applyForcedRetention).not.toHaveBeenCalled(); + }); + }); + + describe('PUT /:conversationId/:messageId/feedback', () => { + const { updateMessage } = require('~/models'); + + it('re-stamps the chat when feedback is the first write after the mode is enabled', async () => { + updateMessage.mockResolvedValue({ + messageId, + conversationId, + endpoint: 'openAI', + feedback: { rating: 'thumbsDown', tag: 'inaccurate' }, + }); + + const response = await request(app) + .put(`/api/messages/${conversationId}/${messageId}/feedback`) + .send({ feedback: { rating: 'thumbsDown', tag: 'inaccurate' } }); + + expect(response.status).toBe(200); + expect(applyForcedRetention).toHaveBeenCalledWith( + { stampForcedRetention }, + { + ctx: expect.objectContaining({ userId, interfaceConfig }), + conversationId, + messageId, + }, + ); + }); + + it('re-stamps the stored conversation, not the one named in the route', async () => { + updateMessage.mockResolvedValue({ + messageId, + conversationId: 'conversation-2', + endpoint: 'openAI', + feedback: { rating: 'thumbsDown', tag: 'inaccurate' }, + }); + + const response = await request(app) + .put(`/api/messages/${conversationId}/${messageId}/feedback`) + .send({ feedback: { rating: 'thumbsDown', tag: 'inaccurate' } }); + + expect(response.status).toBe(200); + expect(applyForcedRetention).toHaveBeenCalledWith( + { stampForcedRetention }, + expect.objectContaining({ conversationId: 'conversation-2', messageId }), + ); + }); + }); + + describe('POST /branch', () => { + const agentId = 'agent-1'; + const sourceMessage = { + messageId, + conversationId, + parentMessageId: 'parent-1', + isCreatedByUser: false, + isTemporary: false, + content: [{ type: 'text', text: 'branched part', agentId }], + }; + + beforeEach(() => { + getMessage.mockResolvedValue(sourceMessage); + saveMessage.mockImplementation((_ctx, message) => + Promise.resolve({ ...message, expiredAt: undefined }), + ); + }); + + it('re-stamps the conversation the branch was created in', async () => { + const response = await request(app).post('/api/messages/branch').send({ messageId, agentId }); + + expect(response.status).toBe(201); + expect(applyForcedRetention).toHaveBeenCalledWith( + { stampForcedRetention }, + { + ctx: expect.objectContaining({ userId, interfaceConfig }), + conversationId, + }, + ); + }); + + it('does not re-stamp when the branch message cannot be saved', async () => { + saveMessage.mockResolvedValue(null); + + const response = await request(app).post('/api/messages/branch').send({ messageId, agentId }); + + expect(response.status).toBe(500); + expect(applyForcedRetention).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/api/server/routes/__tests__/messages-update.spec.js b/api/server/routes/__tests__/messages-update.spec.js new file mode 100644 index 00000000000..a58e3cd4750 --- /dev/null +++ b/api/server/routes/__tests__/messages-update.spec.js @@ -0,0 +1,179 @@ +const express = require('express'); +const request = require('supertest'); +const { ContentTypes, RetentionMode } = require('librechat-data-provider'); + +jest.mock('@librechat/agents', () => ({ + sleep: jest.fn(), +})); + +jest.mock('@librechat/api', () => ({ + unescapeLaTeX: jest.fn((value) => value), + countTokens: jest.fn().mockResolvedValue(10), + createContentFilter: jest.fn(() => (_req, _res, next) => next()), + sendFeedbackScore: jest.fn().mockResolvedValue(undefined), + traceIdForMessage: jest.fn((messageId) => `trace-${messageId}`), + mergeQuotedTextForCount: jest.fn((text) => text), + assertStoredMessageMutationAllowed: jest.fn(), + assertChatMutationAllowed: jest.fn(), + assertStoredMessageBranchAllowed: jest.fn(), + mergeUserSubmittedPaths: (...lists) => [...new Set(lists.flat().filter(Boolean))], + mergeUserSubmittedMessageFieldPaths: (...lists) => lists.flat().filter(Boolean), + isContentFilterError: jest.fn(() => false), + CHILD_THREAD_READ_ONLY_ERROR: 'Child thread is view-only.', + isSubagentThreadWriteBlocked: jest.fn().mockResolvedValue(false), + requireFeedbackEnabled: (req, res, next) => next(), + applyForcedRetention: jest.fn(), +})); + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({})); + +jest.mock('~/models', () => ({ + stampForcedRetention: jest.fn(), + saveConvo: jest.fn(), + getConvo: jest.fn(), + getMessage: jest.fn(), + saveMessage: jest.fn(), + getMessages: jest.fn(), + getFiles: jest.fn().mockResolvedValue([]), + updateMessage: jest.fn(), + deleteMessages: jest.fn(), +})); + +jest.mock('~/server/services/Artifacts/update', () => ({ + findAllArtifacts: jest.fn(), + replaceArtifactContent: jest.fn(), +})); + +jest.mock('~/server/middleware', () => ({ + requireJwtAuth: (req, res, next) => next(), + validateMessageReq: (req, res, next) => next(), + configMiddleware: jest.fn((req, res, next) => next()), + sendValidationResponse: jest.fn(), + canReadActiveJobConversation: jest.fn().mockResolvedValue(false), + prepareMessageRequestValidation: jest.fn(), +})); + +/** + * The route's job is to hand the edited message to the retention helper. Whether it writes is + * decided by the retention mode (`packages/api/src/conversations/retention.spec.ts`), and which + * deadline it writes comes from the stored row (`stampForcedRetention` in + * `packages/data-schemas/src/methods/conversation.spec.ts`). + */ +describe('PUT /:conversationId/:messageId', () => { + let app; + const { configMiddleware } = require('~/server/middleware'); + const { getMessages, stampForcedRetention, updateMessage } = require('~/models'); + const { countTokens, applyForcedRetention } = require('@librechat/api'); + + const userId = 'user-1'; + const conversationId = 'conversation-1'; + const messageId = 'message-1'; + const interfaceConfig = { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 }; + + const expectRestamp = (ctx) => + expect(applyForcedRetention).toHaveBeenCalledWith( + { stampForcedRetention }, + { ctx: expect.objectContaining(ctx), conversationId, messageId }, + ); + + beforeAll(() => { + const messagesRouter = require('../messages'); + + app = express(); + app.use(express.json()); + app.use((req, res, next) => { + req.user = { id: userId }; + req.config = { interfaceConfig }; + next(); + }); + app.use('/api/messages', messagesRouter); + }); + + beforeEach(() => { + jest.clearAllMocks(); + configMiddleware.mockImplementation((req, res, next) => next()); + updateMessage.mockImplementation((authenticatedUserId, payload) => + Promise.resolve({ + messageId: payload.messageId, + conversationId, + text: payload.text, + content: payload.content, + tokenCount: payload.tokenCount, + }), + ); + }); + + it('applies forced retention when editing message text', async () => { + getMessages.mockResolvedValue([{ conversationId, quotes: [], isCreatedByUser: true }]); + + const response = await request(app) + .put(`/api/messages/${conversationId}/${messageId}`) + .send({ text: 'edited text', model: 'gpt-5' }); + + expect(response.status).toBe(200); + expect(countTokens).toHaveBeenCalledWith('edited text', 'gpt-5'); + expect(updateMessage).toHaveBeenCalledWith( + userId, + expect.objectContaining({ messageId, text: 'edited text', tokenCount: 10 }), + ); + expectRestamp({ userId, interfaceConfig }); + }); + + it('applies forced retention when editing a text content part', async () => { + getMessages.mockResolvedValue([ + { + conversationId, + content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'old text' }], + tokenCount: 8, + }, + ]); + + const response = await request(app) + .put(`/api/messages/${conversationId}/${messageId}`) + .send({ text: 'new text', index: 0, model: 'gpt-5' }); + + expect(response.status).toBe(200); + expect(updateMessage).toHaveBeenCalledWith( + userId, + expect.objectContaining({ + messageId, + content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'new text' }], + }), + ); + expectRestamp({ userId, interfaceConfig }); + }); + + it('rejects a message that belongs to another conversation', async () => { + getMessages.mockResolvedValue([{ conversationId: 'conversation-2' }]); + + const response = await request(app) + .put(`/api/messages/${conversationId}/${messageId}`) + .send({ text: 'edited text', model: 'gpt-5' }); + + expect(response.status).toBe(404); + expect(updateMessage).not.toHaveBeenCalled(); + expect(applyForcedRetention).not.toHaveBeenCalled(); + }); + + it('does not apply retention when the message update fails', async () => { + getMessages.mockResolvedValue([{ conversationId, quotes: [], isCreatedByUser: false }]); + updateMessage.mockRejectedValue(new Error('Message not found or user not authorized.')); + + const response = await request(app) + .put(`/api/messages/${conversationId}/${messageId}`) + .send({ text: 'edited text' }); + + expect(response.status).toBe(500); + expect(applyForcedRetention).not.toHaveBeenCalled(); + }); +}); diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js index 4084649e405..060c463577a 100644 --- a/api/server/routes/__tests__/share.spec.js +++ b/api/server/routes/__tests__/share.spec.js @@ -128,27 +128,29 @@ jest.mock('@librechat/data-schemas', () => ({ SystemCapabilities: { ACCESS_ADMIN: 'access:admin' }, })); -jest.mock('librechat-data-provider', () => ({ - PermissionTypes: { - SHARED_LINKS: 'SHARED_LINKS', - }, - Permissions: { - CREATE: 'CREATE', - SHARE_PUBLIC: 'SHARE_PUBLIC', - }, - RetentionMode: { - ALL: 'all', - TEMPORARY: 'temporary', - }, - FileSources: { - local: 'local', - s3: 's3', - cloudfront: 'cloudfront', - azure_blob: 'azure_blob', - firebase: 'firebase', - text: 'text', - }, -})); +jest.mock('librechat-data-provider', () => { + const RetentionMode = { ALL: 'all', TEMPORARY: 'temporary', EPHEMERAL: 'ephemeral' }; + return { + PermissionTypes: { + SHARED_LINKS: 'SHARED_LINKS', + }, + Permissions: { + CREATE: 'CREATE', + SHARE_PUBLIC: 'SHARE_PUBLIC', + }, + RetentionMode, + isAllDataRetention: (mode) => mode === RetentionMode.ALL || mode === RetentionMode.EPHEMERAL, + isForcedTemporaryRetention: (mode) => mode === RetentionMode.EPHEMERAL, + FileSources: { + local: 'local', + s3: 's3', + cloudfront: 'cloudfront', + azure_blob: 'azure_blob', + firebase: 'firebase', + text: 'text', + }, + }; +}); jest.mock('mongoose', () => ({ models: { diff --git a/api/server/routes/agents/__tests__/streamTenant.spec.js b/api/server/routes/agents/__tests__/streamTenant.spec.js index d3e3f28afeb..1003e99ea78 100644 --- a/api/server/routes/agents/__tests__/streamTenant.spec.js +++ b/api/server/routes/agents/__tests__/streamTenant.spec.js @@ -508,6 +508,25 @@ describe('SSE stream tenant isolation', () => { expect(res.body.active).toBe(true); }); + it.each([true, false])( + 'reports the run temporary state %s recorded at admission', + async (isTemporary) => { + mockUserId = 'user-123'; + mockTenantId = 'tenant-a'; + mockGenerationJobManager.getJob.mockResolvedValue({ + metadata: { userId: 'user-123', tenantId: 'tenant-a', isTemporary }, + status: 'running', + createdAt: Date.now(), + }); + mockGenerationJobManager.getResumeState.mockResolvedValue(null); + + const res = await request(app).get('/agents/chat/status/conv-123'); + + expect(res.status).toBe(200); + expect(res.body.isTemporary).toBe(isTemporary); + }, + ); + it('preserves the immutable v2 marker on an active status response', async () => { mockGenerationJobManager.getJob.mockResolvedValue({ metadata: { userId: 'user-123', generationProtocolVersion: 2 }, diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index 0a4cb3290d5..2442868e5d1 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -550,6 +550,7 @@ router.get('/chat/status/:conversationId', async (req, res) => { aggregatedContent: resumeState?.aggregatedContent ?? [], createdAt: job.createdAt, elapsedMs: getGenerationElapsedMs(job), + isTemporary: job.metadata?.isTemporary === true, resumeState, // Surface the live pending approval so a client rebuilding from /chat/status // (reload / cross-replica) has the action id + payload to render and submit diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 77e68cd63bb..f901e48dbf4 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -851,6 +851,7 @@ router.post('/fork', forkIpLimiter, forkUserLimiter, configMiddleware, async (re records: true, splitAtTarget, option, + interfaceConfig: req.config?.interfaceConfig, filters: req.config?.filters, ...(req.config?.messageFilter?.pii == null ? {} @@ -884,6 +885,7 @@ router.post( userId: req.user.id, conversationId, title, + interfaceConfig: req.config?.interfaceConfig, filters: req.config?.filters, ...(req.config?.messageFilter?.pii == null ? {} diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index 6ac391c18d4..b07e37c01d1 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -27,6 +27,7 @@ const { mergeUserSubmittedMessageFieldPaths, isContentFilterError, withoutTraceRefs, + applyForcedRetention, } = require('@librechat/api'); const subagentThreadTaskStore = require('~/server/services/Endpoints/agents/subagentThreadStore'); const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update'); @@ -40,6 +41,8 @@ const { } = require('~/server/middleware'); const db = require('~/models'); +const retentionStore = { stampForcedRetention: db.stampForcedRetention }; + const router = express.Router(); const filterStoredMessageContent = createContentFilter({ messageCount: 1, @@ -374,6 +377,16 @@ router.post('/branch', configMiddleware, async (req, res) => { return res.status(500).json({ error: 'Failed to save branch message' }); } + await applyForcedRetention(retentionStore, { + ctx: { + userId, + isTemporary: sourceMessage.isTemporary, + expiredAt: savedMessage.expiredAt ?? sourceMessage.expiredAt, + interfaceConfig: req?.config?.interfaceConfig, + }, + conversationId: sourceMessage.conversationId, + }); + res.status(201).json(toClientMessage(savedMessage)); } catch (error) { if (isContentFilterError(error)) { @@ -450,13 +463,15 @@ router.post('/artifact/:messageId', configMiddleware, async (req, res) => { : { text: updatedText }; assertStoredMessageMutationAllowed(req.config?.filters, filteredArtifact); + const reqCtx = { + userId: req?.user?.id, + isTemporary: message.isTemporary, + expiredAt: message.expiredAt, + interfaceConfig: req?.config?.interfaceConfig, + }; + const context = 'POST /api/messages/artifact/:messageId'; const savedMessage = await db.saveMessage( - { - userId: req?.user?.id, - isTemporary: message.isTemporary, - expiredAt: message.expiredAt, - interfaceConfig: req?.config?.interfaceConfig, - }, + reqCtx, { messageId, conversationId: message.conversationId, @@ -470,8 +485,12 @@ router.post('/artifact/:messageId', configMiddleware, async (req, res) => { ), user: req.user.id, }, - { context: 'POST /api/messages/artifact/:messageId' }, + { context }, ); + await applyForcedRetention(retentionStore, { + ctx: reqCtx, + conversationId: message.conversationId, + }); res.status(200).json({ conversationId: savedMessage.conversationId, @@ -599,6 +618,7 @@ router.put('/:conversationId/:messageId', messageMutationMiddleware, async (req, if (index !== undefined && (typeof index !== 'number' || index < 0)) { return res.status(400).json({ error: 'Invalid index' }); } + const reqCtx = { userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig }; if (index === undefined) { assertStoredMessageMutationAllowed(req.config?.filters, { text }); @@ -623,6 +643,11 @@ router.put('/:conversationId/:messageId', messageMutationMiddleware, async (req, tokenCount, userSubmittedPaths: mergeUserSubmittedPaths(message.userSubmittedPaths, '/text'), }); + await applyForcedRetention(retentionStore, { + ctx: reqCtx, + conversationId, + messageId, + }); return res.status(200).json(result); } @@ -680,6 +705,11 @@ router.put('/:conversationId/:messageId', messageMutationMiddleware, async (req, `/content/${index}/${currentPartType}`, ), }); + await applyForcedRetention(retentionStore, { + ctx: reqCtx, + conversationId, + messageId, + }); return res.status(200).json(result); } catch (error) { if (isContentFilterError(error)) { @@ -738,6 +768,12 @@ router.put( }).catch((err) => logger.error('[langfuse] feedback score failed:', err)); } + await applyForcedRetention(retentionStore, { + ctx: { userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig }, + conversationId: updatedMessage.conversationId, + messageId, + }); + res.json({ messageId, conversationId, diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js index 1a5bac88fb2..82dcfae4d65 100644 --- a/api/server/services/AuthService.spec.js +++ b/api/server/services/AuthService.spec.js @@ -1,3 +1,4 @@ +/** Installed packages need real module IDs when Jest reuses a resolver across suites. */ jest.mock( '@librechat/data-schemas', () => ({ @@ -7,7 +8,7 @@ jest.mock( DEFAULT_SESSION_EXPIRY: 900000, DEFAULT_REFRESH_TOKEN_EXPIRY: 604800000, }), - { virtual: true }, + { virtual: false }, ); jest.mock( 'librechat-data-provider', @@ -16,7 +17,7 @@ jest.mock( SystemRoles: { USER: 'USER', ADMIN: 'ADMIN' }, errorsToString: jest.fn(), }), - { virtual: true }, + { virtual: false }, ); jest.mock( '@librechat/api', @@ -95,7 +96,7 @@ jest.mock( CLOUDFRONT_SCOPE_COOKIE: 'LibreChat-CloudFront-Scope', }; }, - { virtual: true }, + { virtual: false }, ); jest.mock('~/models', () => ({ findUser: jest.fn(), diff --git a/api/server/services/Endpoints/assistants/title.test.js b/api/server/services/Endpoints/assistants/title.test.js index f22d87eec3e..93bc3e765ad 100644 --- a/api/server/services/Endpoints/assistants/title.test.js +++ b/api/server/services/Endpoints/assistants/title.test.js @@ -32,6 +32,17 @@ describe('assistants addTitle content policy', () => { jest.clearAllMocks(); }); + it('skips the title provider, cache, and save for a normalized temporary request', async () => { + await addTitle( + { user: { id: 'user-1' }, body: { isTemporary: true } }, + { text: 'temporary input', responseText: 'response', conversationId: 'conversation-1' }, + ); + + expect(mockInitializeClient).not.toHaveBeenCalled(); + expect(mockCache.set).not.toHaveBeenCalled(); + expect(mockSaveConvo).not.toHaveBeenCalled(); + }); + it('replaces a blocked generated title before caching or saving it', async () => { const create = jest.fn().mockResolvedValue({ choices: [{ message: { content: 'BLOCKED-GENERATED-TITLE' } }], diff --git a/api/server/services/Threads/manage.retention.spec.js b/api/server/services/Threads/manage.retention.spec.js index c3469ecb4b4..8c59dd5d037 100644 --- a/api/server/services/Threads/manage.retention.spec.js +++ b/api/server/services/Threads/manage.retention.spec.js @@ -177,4 +177,42 @@ describe('Assistants message retention', () => { history.mockRestore(); } }); + + it('forces the whole turn temporary under ephemeral retention', async () => { + const user = new mongoose.Types.ObjectId().toString(); + const conversationId = v4(); + const req = { + user: { id: user }, + body: { conversationId, isTemporary: false }, + resolvedConversation: null, + config: { + interfaceConfig: { retentionMode: 'ephemeral', temporaryChatRetention: 1 }, + }, + }; + const params = { + user, + conversationId, + endpoint: 'assistants', + assistant_id: 'asst_test', + thread_id: 'thread_test', + text: 'hello', + }; + const startedAt = Date.now(); + const userMessage = await saveUserMessage(req, { ...params, messageId: v4() }); + await saveAssistantMessage(req, { + ...params, + messageId: v4(), + parentMessageId: userMessage.messageId, + content: [], + }); + + const rows = await Message.find({ user, conversationId }).lean(); + const convo = await Conversation.findOne({ user, conversationId }).lean(); + expect(rows).toHaveLength(2); + for (const row of [...rows, convo]) { + expect(row.isTemporary).toBe(true); + expect(row.expiredAt.getTime()).toBeGreaterThanOrEqual(startedAt + 3600000); + expect(row.expiredAt.getTime()).toBeLessThan(startedAt + 3600000 + 5000); + } + }); }); diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js index b777eed80da..861e612e225 100644 --- a/api/server/utils/import/fork.js +++ b/api/server/utils/import/fork.js @@ -1,5 +1,10 @@ const { v4: uuidv4 } = require('uuid'); -const { cloneLineage, withoutTraceRefs, getAllMessagesUpToParent } = require('@librechat/api'); +const { + cloneLineage, + withoutTraceRefs, + isTemporaryRecord, + getAllMessagesUpToParent, +} = require('@librechat/api'); const { logger, tenantStorage } = require('@librechat/data-schemas'); const { EModelEndpoint, Constants, ForkOptions } = require('librechat-data-provider'); const { getConvo, getMessages, getSharedMessages } = require('~/models'); @@ -51,6 +56,7 @@ function cloneMessagesWithTimestamps( * @param {boolean} [params.records=false] - Optional flag for returning actual database records or resulting conversation and messages. * @param {boolean} [params.splitAtTarget=false] - Optional flag for splitting the messages at the target message level. * @param {string} [params.latestMessageId] - latestMessageId - Required if splitAtTarget is true. + * @param {object} [params.interfaceConfig] - Runtime interface config used to apply retention to cloned records. * @param {object} [params.filters] - Source-aware content filters applied before cloned records are persisted. * @param {object} [params.legacyPii] - Legacy messageFilter.pii applied before cloned records are persisted. * @param {(userId: string, interfaceConfig?: object, filters?: object, legacyPii?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance. @@ -68,6 +74,7 @@ async function forkConversation({ filters, legacyPii, builderFactory = createImportBatchBuilder, + interfaceConfig, }) { try { const originalConvo = await getConvo(requestUserId, originalConvoId); @@ -86,8 +93,9 @@ async function forkConversation({ const importBatchBuilder = legacyPii == null - ? builderFactory(requestUserId, undefined, filters) - : builderFactory(requestUserId, undefined, filters, legacyPii); + ? builderFactory(requestUserId, interfaceConfig, filters) + : builderFactory(requestUserId, interfaceConfig, filters, legacyPii); + importBatchBuilder.sourceIsTemporary = isTemporaryRecord(originalConvo); importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI); let messagesToClone = []; @@ -479,6 +487,7 @@ async function forkSharedConversation({ * @param {string} params.userId - The ID of the user duplicating the conversation. * @param {string} params.conversationId - The ID of the conversation to duplicate. * @param {string} [params.title] - Optional title override for the duplicate. + * @param {object} [params.interfaceConfig] - Runtime interface config used to apply retention to cloned records. * @param {object} [params.filters] - Source-aware content filters applied before cloned records are persisted. * @param {object} [params.legacyPii] - Legacy messageFilter.pii applied before cloned records are persisted. * @param {(userId: string, interfaceConfig?: object, filters?: object, legacyPii?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance. @@ -488,6 +497,7 @@ async function duplicateConversation({ userId, conversationId, title, + interfaceConfig, filters, legacyPii, builderFactory = createImportBatchBuilder, @@ -509,8 +519,9 @@ async function duplicateConversation({ const importBatchBuilder = legacyPii == null - ? builderFactory(userId, undefined, filters) - : builderFactory(userId, undefined, filters, legacyPii); + ? builderFactory(userId, interfaceConfig, filters) + : builderFactory(userId, interfaceConfig, filters, legacyPii); + importBatchBuilder.sourceIsTemporary = isTemporaryRecord(originalConvo); importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI); cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder); diff --git a/api/server/utils/import/fork.spec.js b/api/server/utils/import/fork.spec.js index d53bb8775c1..2273ef2fc5c 100644 --- a/api/server/utils/import/fork.spec.js +++ b/api/server/utils/import/fork.spec.js @@ -1,4 +1,4 @@ -const { Constants, ForkOptions } = require('librechat-data-provider'); +const { Constants, ForkOptions, RetentionMode } = require('librechat-data-provider'); const mockLogger = { debug: jest.fn(), @@ -118,6 +118,28 @@ describe('forkConversation', () => { bulkSaveMessages.mockResolvedValue(null); }); + test('applies ephemeral retention to forked conversation and messages', async () => { + await forkConversation({ + originalConvoId: 'abc123', + targetMessageId: '3', + requestUserId: 'user1', + option: ForkOptions.DIRECT_PATH, + interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 }, + }); + + expect(bulkSaveConvos).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }), + ]), + ); + expect(bulkSaveMessages).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }), + ]), + true, + ); + }); + test('should fork conversation without branches', async () => { const result = await forkConversation({ originalConvoId: 'abc123', @@ -466,6 +488,75 @@ describe('duplicateConversation', () => { bulkIncrementTagCounts.mockResolvedValue(null); }); + test('applies ephemeral retention to duplicated conversation and messages', async () => { + await duplicateConversation({ + userId: 'user1', + conversationId: 'abc123', + interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 }, + }); + + expect(bulkSaveConvos).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }), + ]), + ); + expect(bulkSaveMessages).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }), + ]), + true, + ); + }); + + test('keeps a duplicate of a temporary chat temporary under all-data retention', async () => { + getConvo.mockResolvedValue({ ...mockConversation, isTemporary: true }); + + const result = await duplicateConversation({ + userId: 'user1', + conversationId: 'abc123', + interfaceConfig: { retentionMode: RetentionMode.ALL, generalChatRetention: 2160 }, + }); + + expect(result.conversation.isTemporary).toBe(true); + }); + + test('keeps a duplicate of a legacy temporary chat temporary under all-data retention', async () => { + getConvo.mockResolvedValue({ ...mockConversation, expiredAt: new Date(Date.now() + 60_000) }); + + await duplicateConversation({ + userId: 'user1', + conversationId: 'abc123', + interfaceConfig: { retentionMode: RetentionMode.ALL, generalChatRetention: 2160 }, + }); + + expect(bulkSaveConvos.mock.calls[0][0][0].isTemporary).toBe(true); + }); + + test('leaves a duplicate of an ordinary chat visible under all-data retention', async () => { + getConvo.mockResolvedValue({ ...mockConversation, isTemporary: false }); + + const result = await duplicateConversation({ + userId: 'user1', + conversationId: 'abc123', + interfaceConfig: { retentionMode: RetentionMode.ALL, generalChatRetention: 2160 }, + }); + + expect(result.conversation.isTemporary).toBe(false); + }); + + test('neither counts nor stores tags on forced-temporary duplicates', async () => { + getConvo.mockResolvedValue({ ...mockConversation, tags: ['important', 'work'] }); + + await duplicateConversation({ + userId: 'user1', + conversationId: 'abc123', + interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 }, + }); + + expect(bulkIncrementTagCounts.mock.calls.flatMap(([, tags]) => tags)).toEqual([]); + expect(bulkSaveConvos.mock.calls[0][0].map((convo) => convo.tags)).toEqual([[]]); + }); + test('should duplicate conversation and increment tag counts', async () => { const mockConvoWithTags = { ...mockConversation, diff --git a/api/server/utils/import/importBatchBuilder.js b/api/server/utils/import/importBatchBuilder.js index 50e05a0a3d3..f7421fce57c 100644 --- a/api/server/utils/import/importBatchBuilder.js +++ b/api/server/utils/import/importBatchBuilder.js @@ -5,6 +5,8 @@ const { assertConversationImportContentAllowed, reportLocatorTraversalFailure, executeConversationImportWrites, + resolveImportRetentionFields, + resolveImportTagCounts, } = require('@librechat/api'); const { getTenantId, @@ -12,12 +14,7 @@ const { createFallbackRetentionDate, createChatExpirationDate, } = require('@librechat/data-schemas'); -const { - EModelEndpoint, - Constants, - RetentionMode, - openAISettings, -} = require('librechat-data-provider'); +const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider'); const { bulkIncrementTagCounts, bulkSaveConvos, @@ -81,26 +78,17 @@ class ImportBatchBuilder { this.conversations = []; this.messages = []; this.retentionFields = undefined; + /** Set by a fork or duplicate so the copy keeps its source's temporary classification. */ + this.sourceIsTemporary = undefined; } getRetentionFields() { - if (this.retentionFields !== undefined) { - return this.retentionFields; - } - - if (this.interfaceConfig?.retentionMode !== RetentionMode.ALL) { - this.retentionFields = {}; - return this.retentionFields; - } - - try { - this.retentionFields = { - isTemporary: false, - expiredAt: createChatExpirationDate(this.interfaceConfig), - }; - } catch (error) { - logger.error('[ImportBatchBuilder] Error creating import expiration date:', error); - this.retentionFields = { isTemporary: false, expiredAt: createFallbackRetentionDate() }; + if (this.retentionFields === undefined) { + this.retentionFields = resolveImportRetentionFields( + this.interfaceConfig, + { createChatExpirationDate, createFallbackRetentionDate, logger }, + { sourceIsTemporary: this.sourceIsTemporary }, + ); } return this.retentionFields; } @@ -174,6 +162,9 @@ class ImportBatchBuilder { endpoint: this.endpoint, model: originalConvo.model ?? fallbackModel, ...this.getRetentionFields(), + ...(originalConvo.tags != null && { + tags: resolveImportTagCounts(this.getRetentionFields(), originalConvo.tags), + }), }; convo._id && delete convo._id; delete convo.subagentThread; @@ -221,7 +212,10 @@ class ImportBatchBuilder { conversationIds, ...(tenantId == null ? {} : { tenantId }), }; - const tags = this.conversations.flatMap((convo) => convo.tags); + const tags = resolveImportTagCounts( + this.getRetentionFields(), + this.conversations.flatMap((convo) => convo.tags), + ); try { await executeConversationImportWrites({ diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js index b9d945af94c..b4b2062ed82 100644 --- a/api/server/utils/import/importers.spec.js +++ b/api/server/utils/import/importers.spec.js @@ -1355,6 +1355,23 @@ describe('importLibreChatConvo', () => { expect(message.expiredAt.getTime()).toBeLessThan(now + hours * 3600000 + 1000); }, ); + + it('marks imported conversations and messages temporary under ephemeral retention', () => { + const requestUserId = 'user-123'; + const builder = new ImportBatchBuilder(requestUserId, { + retentionMode: RetentionMode.EPHEMERAL, + temporaryChatRetention: 24, + }); + builder.startConversation(EModelEndpoint.openAI); + const message = builder.addUserMessage('Ephemeral import'); + const result = builder.finishConversation('Imported ephemeral chat'); + + expect(message.isTemporary).toBe(true); + expect(message.expiredAt).toBeInstanceOf(Date); + expect(result.conversation.isTemporary).toBe(true); + expect(result.conversation.expiredAt).toBeInstanceOf(Date); + expect(result.conversation.expiredAt).toBe(message.expiredAt); + }); }); }); diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx index fd50568afaf..d30496d8eff 100644 --- a/client/src/components/Chat/Header.tsx +++ b/client/src/components/Chat/Header.tsx @@ -6,6 +6,7 @@ import { Constants, PermissionTypes, Permissions, + isForcedTemporaryRetention, } from 'librechat-data-provider'; import { OpenSidebar, PresetsMenu, NewChat, HeaderMenu } from './Menus'; import { TemporaryChat, TemporaryChatIndicator } from './TemporaryChat'; @@ -66,6 +67,10 @@ function Header({ permissionType: PermissionTypes.TEMPORARY_CHAT, permission: Permissions.USE, }); + /** An administrator-enforced mode is not a role grant, so it is overlaid here rather than + * written into the role's stored permissions; the control is read-only either way. */ + const showTemporaryChat = + hasAccessToTemporaryChat === true || isForcedTemporaryRetention(interfaceConfig.retentionMode); /** Child threads are view-only records of their parent's run and have no trace of their own. */ const trace = useTraceControl({ @@ -112,7 +117,7 @@ function Header({
- {hasAccessToTemporaryChat === true && } + {showTemporaryChat && } {!isNewChat && } {!isNewChat && parentConversationId == null && ( {trace.show && } - {hasAccessToTemporaryChat === true && } + {showTemporaryChat && }
diff --git a/client/src/components/Chat/Menus/HeaderMenu.tsx b/client/src/components/Chat/Menus/HeaderMenu.tsx index fd8961c2129..ca6229fd9ca 100644 --- a/client/src/components/Chat/Menus/HeaderMenu.tsx +++ b/client/src/components/Chat/Menus/HeaderMenu.tsx @@ -57,7 +57,8 @@ export default function HeaderMenu({ const showBookmarks = hasAccessToBookmarks === true && bookmarks.show; const showCompare = hasAccessToMultiConvo === true && multiConvo.show; - const showTemporary = hasAccessToTemporaryChat === true && temporary.show; + const showTemporary = + (hasAccessToTemporaryChat === true || temporary.isEnforced) && temporary.show; const items: t.MenuItemProps[] = []; @@ -107,8 +108,11 @@ export default function HeaderMenu({ if (showTemporary) { pushGroup({ id: 'header-temporary', - label: localize('com_ui_temporary'), + label: temporary.isEnforced + ? localize('com_ui_temporary_enforced') + : localize('com_ui_temporary'), ariaChecked: temporary.isTemporary, + disabled: temporary.isEnforced, className: temporary.isTemporary ? 'bg-surface-active' : undefined, icon: temporary.isTemporary ? ( diff --git a/client/src/components/Chat/Menus/__tests__/BookmarkMenu.spec.tsx b/client/src/components/Chat/Menus/__tests__/BookmarkMenu.spec.tsx new file mode 100644 index 00000000000..af92a51c903 --- /dev/null +++ b/client/src/components/Chat/Menus/__tests__/BookmarkMenu.spec.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { render, screen } from '@testing-library/react'; +import { RetentionMode } from 'librechat-data-provider'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { TConversation } from 'librechat-data-provider'; +import BookmarkMenu from '../BookmarkMenu'; +import store from '~/store'; + +const mockUseGetStartupConfig = jest.fn(); + +jest.mock('~/data-provider', () => ({ + useGetStartupConfig: (...args: unknown[]) => mockUseGetStartupConfig(...args), + useConversationTagsQuery: () => ({ data: [] }), + useTagConversationMutation: () => ({ mutate: jest.fn(), isLoading: false }), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, + useBookmarkSuccess: () => jest.fn(), +})); + +jest.mock('@librechat/client', () => { + const ReactActual = jest.requireActual('react'); + return { + DropdownPopup: ({ trigger }: { trigger: React.ReactNode }) => + ReactActual.createElement('div', null, trigger), + TooltipAnchor: ({ render }: { render: React.ReactNode }) => render, + Spinner: () => null, + useToastContext: () => ({ showToast: jest.fn() }), + }; +}); + +jest.mock('@ariakit/react', () => { + const ReactActual = jest.requireActual('react'); + return { + MenuButton: (props: React.ButtonHTMLAttributes) => + ReactActual.createElement('button', props, props.children), + }; +}); + +jest.mock('~/components/Bookmarks', () => ({ + BookmarkEditDialog: () => null, +})); + +const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +function renderMenu({ + conversation, + retentionMode, +}: { + conversation: Partial; + retentionMode?: RetentionMode; +}) { + mockUseGetStartupConfig.mockReturnValue({ + data: retentionMode ? { interface: { retentionMode } } : { interface: {} }, + }); + + return render( + + { + set(store.conversationByIndex(0), conversation as TConversation); + }} + > + + + , + ); +} + +describe('BookmarkMenu', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows bookmark controls for a permanent conversation', () => { + renderMenu({ + conversation: { conversationId: 'convo-1', isTemporary: false }, + retentionMode: RetentionMode.TEMPORARY, + }); + + expect(screen.getByTestId('bookmark-menu')).toBeInTheDocument(); + }); + + it('hides bookmark controls for a temporary conversation', () => { + renderMenu({ + conversation: { conversationId: 'convo-1', isTemporary: true }, + retentionMode: RetentionMode.TEMPORARY, + }); + + expect(screen.queryByTestId('bookmark-menu')).not.toBeInTheDocument(); + }); + + it('hides bookmark controls when ephemeral retention is forced on a permanent conversation', () => { + renderMenu({ + conversation: { conversationId: 'convo-1', isTemporary: false }, + retentionMode: RetentionMode.EPHEMERAL, + }); + + expect(screen.queryByTestId('bookmark-menu')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Chat/TemporaryChat.tsx b/client/src/components/Chat/TemporaryChat.tsx index f5e5e2f90b8..0ae2ae038d5 100644 --- a/client/src/components/Chat/TemporaryChat.tsx +++ b/client/src/components/Chat/TemporaryChat.tsx @@ -7,7 +7,7 @@ import { cn } from '~/utils'; export function TemporaryChat() { const localize = useLocalize(); - const { show, isTemporary, toggle } = useTemporaryChat(); + const { show, isTemporary, isEnforced, toggle } = useTemporaryChat(); const tooltipDescription = useShortcutHint('toggleTemporaryChat', localize('com_ui_temporary')); const ariaKey = useShortcutAriaKey('toggleTemporaryChat'); @@ -15,21 +15,25 @@ export function TemporaryChat() { return null; } + const label = isEnforced ? localize('com_ui_temporary_enforced') : localize('com_ui_temporary'); + return (