Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 19 additions & 8 deletions api/app/clients/BaseClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const {
seedTurnConversation,
needsRetentionConversation,
getConversationWriteContext,
savePrivateTextMessage,
stampPrivateTextMessage,
requirePrivateTextPersistence,
} = require('@librechat/api');
const {
Constants,
Expand Down Expand Up @@ -586,13 +589,16 @@ class BaseClient {
} = await this.setMessageOptions(opts);
this.options.startupTelemetry?.mark('history_loaded');

const userMessage = this.resolveStartUserMessage({
opts,
message,
userMessageId,
parentMessageId,
conversationId,
});
const userMessage = stampPrivateTextMessage(
this.options.req,
this.resolveStartUserMessage({
opts,
message,
userMessageId,
parentMessageId,
conversationId,
}),
);

/**
* Attach quoted excerpts (the "Add to chat" selections from `req.body.quotes`)
Expand Down Expand Up @@ -1029,6 +1035,9 @@ class BaseClient {
await balanceReservations.track(balanceAdmission);
}

await requirePrivateTextPersistence(this.options.req, () =>
userMessagePersistence != null ? userMessagePersistence.start() : userMessagePromise,
);
completionResult = await this.sendCompletion(payload, opts);
} catch (error) {
if (userMessagePersistence?.isPending()) {
Expand Down Expand Up @@ -1349,7 +1358,9 @@ class BaseClient {
req.resolvedConversation = await db.getConvo(req.user.id, message.conversationId);
}
const reqCtx = getConversationWriteContext(req);
const savedMessage = await db.saveMessage(
const savedMessage = await savePrivateTextMessage(
db.saveMessage,
req,
reqCtx,
{
...message,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ jest.mock('@librechat/data-schemas', () => ({
}));

jest.mock('@librechat/api', () => ({
savePrivateTextMessage: (save, _req, ...args) => save(...args),
stampPreliminaryPrivateTextMessage: (_req, message) => message,
getAgentErrorMetadata: (...args) =>
jest.requireActual('@librechat/api').getAgentErrorMetadata(...args),
sendEvent: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ jest.mock('@librechat/data-schemas', () => ({
}));

jest.mock('@librechat/api', () => ({
savePrivateTextMessage: (save, _req, ...args) => save(...args),
stampPreliminaryPrivateTextMessage: (_req, message) => message,
getSteerRecoveryFailure: jest.requireActual(
'../../../../../packages/api/src/stream/SteerRecovery',
).getSteerRecoveryFailure,
Expand Down
34 changes: 25 additions & 9 deletions api/server/controllers/agents/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ const {
resolvePersistableCodeEnvironmentDecision,
getFailedTurnTraceFields,
resolveFailedTurnContent,
savePrivateTextMessage,
stampPreliminaryPrivateTextMessage,
} = require('@librechat/api');
const { disposeClient } = require('~/server/cleanup');
const {
Expand Down Expand Up @@ -459,7 +461,9 @@ async function saveErrorTurn(
const iconURL = getEndpointIconURL(req, endpointOption);

if (userMessage) {
const savedUserMessage = await saveMessage(
const savedUserMessage = await savePrivateTextMessage(
saveMessage,
req,
reqCtx,
{
...userMessage,
Expand Down Expand Up @@ -1590,10 +1594,13 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
const responseModel = getAgentResponseModel(req, endpointOption);
const preliminaryUserMessage = isCompaction
? projectCompactionAnchor({ messageId: parentMessageId, conversationId })
: getPreliminaryUserMessage(
{ ...req.body, messageId: preallocatedUserMessageId },
conversationId,
req._agentEventTriggerProjection,
: stampPreliminaryPrivateTextMessage(
req,
getPreliminaryUserMessage(
{ ...req.body, messageId: preallocatedUserMessageId },
conversationId,
req._agentEventTriggerProjection,
),
);
const job = await GenerationJobManager.createJob(streamId, userId, conversationId, {
startupTelemetry,
Expand Down Expand Up @@ -2304,6 +2311,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
conversationId: userMsg.conversationId,
text: userMsg.text,
quotes: userMsg.quotes,
privacyRevision: userMsg.privacyRevision,
// Persist the turn's uploaded files here (authoritative job metadata) so a
// HITL resume sources them from the job, not the user DB row — which the
// approval prompt can race (the row save may still be in flight when a fast
Expand Down Expand Up @@ -2730,7 +2738,9 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
convoSignal.observeMessageWrite(Promise.resolve(savedUserTurn));
} else {
// Custom clients used by integrations/tests may not inherit BaseClient.
const savedUserMessage = await saveMessage(
const savedUserMessage = await savePrivateTextMessage(
saveMessage,
req,
{
userId,
isTemporary:
Expand Down Expand Up @@ -2983,9 +2993,15 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
if (!userMessage) {
throw new Error('User message was unavailable before terminal persistence');
}
const savedUserMessage = await saveMessage(reqCtx, userMessage, {
context: 'api/server/controllers/agents/request.js - resumable user message',
});
const savedUserMessage = await savePrivateTextMessage(
saveMessage,
req,
reqCtx,
userMessage,
{
context: 'api/server/controllers/agents/request.js - resumable user message',
},
);
if (!savedUserMessage) {
throw new Error('User message could not be persisted before terminal publication');
}
Expand Down
42 changes: 29 additions & 13 deletions api/server/middleware/denyRequest.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
const crypto = require('crypto');
const { sendEvent } = require('@librechat/api');
const {
sendEvent,
rejectUnprotectedDeniedMessage,
stampPrivateTextMessage,
savePrivateTextMessage,
} = require('@librechat/api');
const { getResponseSender, Constants } = require('librechat-data-provider');
const { sendError } = require('~/server/middleware/error');
const { saveMessage } = require('~/models');
Expand All @@ -21,28 +26,38 @@ const { saveMessage } = require('~/models');
* @throws {Error} Throws an error if there's an issue saving the message or sending the error.
*/
const denyRequest = async (req, res, errorMessage) => {
if (rejectUnprotectedDeniedMessage(req, res)) {
return;
}

let responseText = errorMessage;
if (typeof errorMessage === 'object') {
responseText = JSON.stringify(errorMessage);
}

const { messageId, conversationId: _convoId, parentMessageId, text } = req.body;
const conversationId = _convoId ?? crypto.randomUUID();
const shouldSaveMessage = Boolean(
_convoId && parentMessageId && parentMessageId !== Constants.NO_PARENT,
);

const userMessage = {
sender: 'User',
messageId: messageId ?? crypto.randomUUID(),
parentMessageId,
conversationId,
isCreatedByUser: true,
text,
};
sendEvent(res, { message: userMessage, created: true });

const shouldSaveMessage = _convoId && parentMessageId && parentMessageId !== Constants.NO_PARENT;
const userMessage = stampPrivateTextMessage(
req,
{
sender: 'User',
messageId: messageId ?? crypto.randomUUID(),
parentMessageId,
conversationId,
isCreatedByUser: true,
text,
},
shouldSaveMessage,
);

if (shouldSaveMessage) {
await saveMessage(
await savePrivateTextMessage(
Comment on lines 57 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve owner text for denied first turns

When a PII-containing first turn is rejected by checkBan or a message limiter, the client sends no conversation ID and uses NO_PARENT, so shouldSaveMessage is false and this protected save is skipped. The preceding created event nevertheless replaces the client's optimistic plaintext with the canonical text and privacyRevision; OwnerTextProvider then queries a sidecar that was never stored and permanently displays “Original unavailable,” including after Retry. Preserve the owner view for this unsaved failure path or avoid stamping an unrecoverable revision.

AGENTS.md reference: AGENTS.md:L42-L44

Useful? React with 👍 / 👎.

saveMessage,
req,
{
userId: req?.user?.id,
isTemporary: req?.resolvedConversation?.isTemporary ?? req?.body?.isTemporary,
Expand All @@ -54,6 +69,7 @@ const denyRequest = async (req, res, errorMessage) => {
);
}

sendEvent(res, { message: userMessage, created: true });
return await sendError(req, res, {
sender: getResponseSender(req.body),
messageId: crypto.randomUUID(),
Expand Down
Loading
Loading