🧵 fix: Reject Preliminary Parent Follow-Ups (#13619)

* fix: Reject preliminary parent follow-ups

* chore: Sort frontend imports

* fix: Narrow preliminary parent detection

* fix: Preserve refused submit state

* fix: Propagate refused submit result
This commit is contained in:
Danny Avila 2026-06-09 12:06:51 -04:00 committed by GitHub
parent 753e53eddd
commit fd4728232c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 426 additions and 16 deletions

View file

@ -4,6 +4,8 @@ import {
sanitizeFileForTransmit,
buildMessageFiles,
getThreadData,
isPreliminaryMessageId,
isUnpersistedPreliminaryParent,
} from './message';
/** Cast to string for type compatibility with ThreadMessage */
@ -130,6 +132,55 @@ describe('sanitizeMessageForTransmit', () => {
});
});
describe('isUnpersistedPreliminaryParent', () => {
it('returns false without querying for non-preliminary parent ids', async () => {
const getMessages = jest.fn();
await expect(
isUnpersistedPreliminaryParent({
userId: 'user-123',
conversationId: 'conversation-123',
parentMessageId: 'persisted-response',
getMessages,
}),
).resolves.toBe(false);
expect(isPreliminaryMessageId('persisted-response')).toBe(false);
expect(getMessages).not.toHaveBeenCalled();
});
it('returns true when the underscore-suffixed parent is not persisted', async () => {
const getMessages = jest.fn().mockResolvedValue([]);
await expect(
isUnpersistedPreliminaryParent({
userId: 'user-123',
conversationId: 'conversation-123',
parentMessageId: 'pending-response_',
getMessages,
}),
).resolves.toBe(true);
expect(getMessages).toHaveBeenCalledWith(
{ user: 'user-123', messageId: 'pending-response_', conversationId: 'conversation-123' },
'_id',
);
});
it('returns false when the underscore-suffixed parent is already persisted', async () => {
const getMessages = jest.fn().mockResolvedValue([{ _id: 'persisted-parent' }]);
await expect(
isUnpersistedPreliminaryParent({
userId: 'user-123',
conversationId: 'conversation-123',
parentMessageId: 'persisted-response_',
getMessages,
}),
).resolves.toBe(false);
});
});
describe('buildMessageFiles', () => {
const baseAttachment = {
file_id: 'file-1',

View file

@ -4,6 +4,11 @@ import type { TFile, TMessage } from 'librechat-data-provider';
/** Minimal shape for request file entries (from `req.body.files`) */
type RequestFile = { file_id?: string };
type GetMessagesByParentId = (
filter: { user: string; messageId: string; conversationId?: string },
select: '_id',
) => Promise<unknown[]>;
/** Fields to strip from files before client transmission */
const FILE_STRIP_FIELDS = ['text', '_id', '__v'] as const;
@ -92,6 +97,37 @@ export function sanitizeMessageForTransmit<T extends Partial<TMessage>>(
return sanitized;
}
export function isPreliminaryMessageId(messageId: unknown): messageId is string {
return typeof messageId === 'string' && messageId.endsWith('_');
}
export async function isUnpersistedPreliminaryParent({
userId,
conversationId,
parentMessageId,
getMessages,
}: {
userId: string;
conversationId?: string | null;
parentMessageId?: string | null;
getMessages: GetMessagesByParentId;
}): Promise<boolean> {
if (!isPreliminaryMessageId(parentMessageId)) {
return false;
}
const filter: { user: string; messageId: string; conversationId?: string } = {
user: userId,
messageId: parentMessageId,
};
if (conversationId && conversationId !== Constants.NEW_CONVO) {
filter.conversationId = conversationId;
}
const messages = await getMessages(filter, '_id');
return messages.length === 0;
}
/** Minimal message shape for thread traversal.
*
* Both `files` and `attachments` carry `file_id` references, but they