Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
142931d
feat: add ephemeral retention mode for forced temporary chats
berry-13 Sep 22, 2026
1950043
fix: enforce ephemeral retention when resuming paused chats
berry-13 Sep 24, 2026
05f7b45
fix: preserve forced retention across pauses and assistant turns
berry-13 Sep 24, 2026
5b05838
test: Use Real Module IDs for Installed Package Mocks
berry-13 Sep 24, 2026
543ecff
test: Wait for Resumed Retention Persistence
berry-13 Sep 24, 2026
5978667
test: Control Mongo Index Setup in OpenID Fixture
berry-13 Sep 24, 2026
df0b099
test: Close Mobile Drawer Before Retention Resume Scenario
berry-13 Sep 24, 2026
637bd0e
fix: Store Responses API Messages Under the Retention Write Context
berry-13 Sep 24, 2026
724ea48
fix: Align Resumed Responses With the Parent Retention Deadline
berry-13 Sep 24, 2026
c1a2a5e
fix: Clear Released Bookmark Tags When Forcing a Chat Temporary
berry-13 Sep 24, 2026
b01043b
fix: Store No Bookmark Tags on Forced-Temporary Imports
berry-13 Sep 24, 2026
8c32b61
test: Provide the Forced-Temporary Retention Helper to the Header Sta…
berry-13 Sep 28, 2026
a57d0ae
test: Reuse One Admin Login per Worker in Ephemeral Retention Scenarios
berry-13 Sep 28, 2026
fe38ba3
fix: Keep Legacy Temporary Chats Temporary When Forking Under All-Dat…
berry-13 Sep 29, 2026
4f54dce
fix: Convert a Resumed Chat Under Ephemeral Retention Before It Can R…
berry-13 Sep 29, 2026
7573fee
fix: Treat a Reloaded Pre-Policy Run as Temporary Once Retention Is F…
berry-13 Sep 30, 2026
246b142
fix: Stamp Messages with the Deadline a Concurrent Retention Stamp St…
berry-13 Sep 30, 2026
e9f9321
fix: Wait for the Startup Config Before Rebuilding a Resumed Submission
berry-13 Sep 30, 2026
cf3504c
test: Wait for the Resumed Response Row in the Ephemeral Resume Scenario
berry-13 Sep 30, 2026
6604d70
test: Provide the Real Command Executor Stamp to the Callbacks Mock
berry-13 Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/server/controllers/agents/__tests__/callbacks.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 39 additions & 1 deletion api/server/controllers/agents/__tests__/responses.unit.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down
90 changes: 90 additions & 0 deletions api/server/controllers/agents/__tests__/resume.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(
Expand Down
22 changes: 4 additions & 18 deletions api/server/controllers/agents/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const {
toPendingSteer,
persistedReasoningOverrideFields,
getViolationInfo,
applyForcedTemporaryRequest,
resolveResumableRetention,
buildMessageFiles,
getReferencedQuotes,
resolveTitleTiming,
Expand Down Expand Up @@ -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);
Comment thread
berry-13 marked this conversation as resolved.
const startupTelemetry = getAgentStartupTelemetry(req);
let generationProtocolVersion = negotiateNewGenerationProtocol(req);
const {
Expand Down Expand Up @@ -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
Expand Down
22 changes: 6 additions & 16 deletions api/server/controllers/agents/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ const {
resolvePersistableCodeEnvironmentDecision,
createTerminalRunErrorObserver,
announceReply,
getConversationWriteContext,
} = require('@librechat/api');
const {
createResponsesToolEndCallback,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -426,7 +427,7 @@ async function saveResponseOutput(

// Save the assistant message
return db.saveMessage(
req,
getConversationWriteContext(req),
{
messageId: responseId,
conversationId,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
});

Expand Down Expand Up @@ -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',
});

Expand Down
Loading
Loading