🧵 fix: Retry Shared Links After Message Persistence Gaps (#15306)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions

* fix: Retry shared links after message persistence gaps

* chore: Sort share test imports

* fix: Address share review feedback

* fix: Normalize hydrated share target
This commit is contained in:
Danny Avila 2026-08-28 08:05:52 -04:00 committed by GitHub
parent c06b09c945
commit 04a7577821
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 594 additions and 79 deletions

View file

@ -5,6 +5,7 @@ const mongoose = require('mongoose');
const mockGetSharedLinkExpiration = jest.fn();
const mockGrantCreationPermissions = jest.fn();
const mockUpdateSharedLinkPermissionsExpiration = jest.fn();
const mockRecordShareLinkRejection = jest.fn();
const mockSharedLinksAccess = jest.fn((_req, _res, next) => next());
const mockSharedLinkConfigMiddleware = jest.fn((_req, _res, next) => next());
let mockShareTenantId;
@ -103,6 +104,8 @@ jest.mock('@librechat/api', () => ({
(...args) =>
mockGetSharedLangfuseSessionUrl(...args),
),
recordShareLinkRejection: (...args) => mockRecordShareLinkRejection(...args),
traceIdForMessage: (messageId) => `trace-${messageId}`,
isContentFilterError: jest.fn(
(error) =>
error?.code === 'content_filter_block' || error?.code === 'content_filter_uninspectable',
@ -113,7 +116,10 @@ jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), warn: jest.fn() },
createTempChatExpirationDate: jest.fn(() => new Date('2030-01-01T00:00:00.000Z')),
runAsSystem: jest.fn((fn) => fn()),
tenantStorage: { run: jest.fn((_ctx, fn) => fn()) },
tenantStorage: {
getStore: jest.fn(() => ({ requestId: 'request-123' })),
run: jest.fn((_ctx, fn) => fn()),
},
SYSTEM_TENANT_ID: '__SYSTEM__',
SystemCapabilities: { ACCESS_ADMIN: 'access:admin' },
}));
@ -832,7 +838,31 @@ describe('share routes', () => {
const response = await request(buildApp()).post('/api/share/convo-123').send({});
expect(response.status).toBe(409);
expect(response.body).toEqual({ message: 'Share already exists' });
expect(response.body).toEqual({ message: 'Share already exists', code: 'SHARE_EXISTS' });
});
it.each([
['TARGET_MESSAGE_NOT_FOUND', 'Target message not found', 'trace-msg-123'],
['NO_MESSAGES', 'No messages to share', 'trace-msg-123'],
])('returns and records the %s create rejection', async (code, message, traceId) => {
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
createSharedLink.mockRejectedValue(Object.assign(new Error(message), { code }));
const response = await request(buildApp())
.post('/api/share/convo-123')
.send({ targetMessageId: 'msg-123' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ message, code });
expect(mockRecordShareLinkRejection).toHaveBeenCalledWith('create', code);
expect(logger.warn).toHaveBeenCalledWith('[share] Shared link publication rejected', {
event: 'share_link_rejected',
operation: 'create',
code,
request_id: 'request-123',
trace_id: traceId,
});
expect(logger.error).not.toHaveBeenCalledWith('Error creating shared link:', expect.anything());
});
it('returns a raw-free 400 when the exact create snapshot fails policy preflight', async () => {
@ -1332,7 +1362,35 @@ describe('share routes', () => {
const response = await request(buildApp()).patch('/api/share/share-123').send({});
expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'Share not found' });
expect(response.body).toEqual({ message: 'Share not found', code: 'SHARE_NOT_FOUND' });
});
it('returns and records a missing-tail update rejection', async () => {
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
updateSharedLink.mockRejectedValue(
Object.assign(new Error('Target message not found'), {
code: 'TARGET_MESSAGE_NOT_FOUND',
}),
);
const response = await request(buildApp())
.patch('/api/share/share-123')
.send({ targetMessageId: 'msg-123' });
expect(response.status).toBe(400);
expect(response.body).toEqual({
message: 'Target message not found',
code: 'TARGET_MESSAGE_NOT_FOUND',
});
expect(mockRecordShareLinkRejection).toHaveBeenCalledWith('update', 'TARGET_MESSAGE_NOT_FOUND');
expect(logger.warn).toHaveBeenCalledWith('[share] Shared link publication rejected', {
event: 'share_link_rejected',
operation: 'update',
code: 'TARGET_MESSAGE_NOT_FOUND',
request_id: 'request-123',
trace_id: 'trace-msg-123',
});
});
it('allows deleting existing shares without CREATE permission gate', async () => {
@ -1466,7 +1524,10 @@ describe('share fork route', () => {
.send({ targetMessageIndex: 3, shareRevision: '2026-01-01T00:00:00.000Z' });
expect(response.status).toBe(409);
expect(response.body).toEqual({ message: 'Shared link was updated' });
expect(response.body).toEqual({
message: 'Shared link was updated',
code: 'SHARE_REVISION_MISMATCH',
});
});
});

View file

@ -21,6 +21,8 @@ const {
MAX_SHARED_LINK_SEARCH_LENGTH,
createSharedLinkConfigMiddleware,
createSharedLangfuseSessionResolver,
recordShareLinkRejection,
traceIdForMessage,
} = require('@librechat/api');
const {
logger,
@ -71,10 +73,30 @@ const SHARE_SERVICE_ERROR_STATUS = {
SHARE_REVISION_MISMATCH: 409,
};
const sendShareServiceError = (res, error, fallbackMessage) => {
const OBSERVABLE_SHARE_REJECTIONS = new Set(['TARGET_MESSAGE_NOT_FOUND', 'NO_MESSAGES']);
const sendShareServiceError = (req, res, error, fallbackMessage, operation) => {
const status = SHARE_SERVICE_ERROR_STATUS[error?.code] ?? 500;
const message = status === 500 ? fallbackMessage : error.message;
return res.status(status).json({ message });
const code = status === 500 ? undefined : error.code;
if (OBSERVABLE_SHARE_REJECTIONS.has(code)) {
const targetMessageId = req.body?.targetMessageId;
const requestId = tenantStorage.getStore()?.requestId ?? req.requestId;
const traceId =
typeof targetMessageId === 'string' ? traceIdForMessage(targetMessageId) : undefined;
recordShareLinkRejection(operation, code);
logger.warn('[share] Shared link publication rejected', {
event: 'share_link_rejected',
operation,
code,
...(requestId && { request_id: requestId }),
...(traceId && { trace_id: traceId }),
});
}
return res.status(status).json({ message, ...(code && { code }) });
};
const checkSharedLinksAccess = generateCheckAccess({
@ -417,7 +439,7 @@ if (allowSharedLinks) {
if (error?.code !== 'SHARE_REVISION_MISMATCH') {
logger.error('Error forking shared conversation:', error);
}
return sendShareServiceError(res, error, 'Error forking shared conversation');
return sendShareServiceError(req, res, error, 'Error forking shared conversation', 'fork');
}
},
);
@ -649,8 +671,10 @@ router.post(
if (isContentFilterError(error)) {
return res.status(error.statusCode).json(error.body);
}
logger.error('Error creating shared link:', error);
return sendShareServiceError(res, error, 'Error creating shared link');
if (!OBSERVABLE_SHARE_REJECTIONS.has(error?.code)) {
logger.error('Error creating shared link:', error);
}
return sendShareServiceError(req, res, error, 'Error creating shared link', 'create');
}
},
);
@ -720,8 +744,10 @@ router.patch(
if (isContentFilterError(error)) {
return res.status(error.statusCode).json(error.body);
}
logger.error('Error updating shared link:', error);
return sendShareServiceError(res, error, 'Error updating shared link');
if (!OBSERVABLE_SHARE_REJECTIONS.has(error?.code)) {
logger.error('Error updating shared link:', error);
}
return sendShareServiceError(req, res, error, 'Error updating shared link', 'update');
}
},
);

View file

@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { QRCodeSVG } from 'qrcode.react';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, dataService } from 'librechat-data-provider';
import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
import {
ESide,
@ -14,7 +16,7 @@ import {
OGDialogContent,
OGDialogDescription,
} from '@librechat/client';
import { useLatestMessageId } from '~/hooks/Messages/useLatestMessage';
import { useGetLatestMessage, useLatestMessageId } from '~/hooks/Messages/useLatestMessage';
import SharedLinkCopyButton from './SharedLinkCopyButton';
import { useGetStartupConfig } from '~/data-provider';
import SharedLinkButton from './SharedLinkButton';
@ -22,6 +24,12 @@ import { buildShareLinkUrl } from '~/utils';
import { useLocalize } from '~/hooks';
import store from '~/store';
type ShareTargetErrorCode = 'TARGET_MESSAGE_NOT_FOUND' | 'NO_MESSAGES';
const createShareTargetError = (
code: ShareTargetErrorCode,
): Error & { code: ShareTargetErrorCode } => Object.assign(new Error(code), { code });
export default function ShareButton({
conversationId,
open,
@ -38,16 +46,42 @@ export default function ShareButton({
const localize = useLocalize();
const { data: startupConfig } = useGetStartupConfig();
const canSnapshotFiles = startupConfig?.sharedLinksSnapshotFilesEnabled === true;
const queryClient = useQueryClient();
const [showQR, setShowQR] = useState(true);
const [sharedLink, setSharedLink] = useState('');
const [snapshotFiles, setSnapshotFiles] = useState(true);
const shareFilesSwitchRef = React.useRef<HTMLButtonElement>(null);
const activeConversationId = useRecoilValue(store.conversationIdByIndex(0));
const activeLatestMessageId = useLatestMessageId(0);
const getActiveLatestMessage = useGetLatestMessage(0);
/** `useLatestMessageId` resolves the active pane's branch tail, so it only describes
* this dialog's conversation when the two match. Sharing another conversation from
* the list sends no target, which shares it in full instead of a foreign message. */
const latestMessageId = activeConversationId === conversationId ? activeLatestMessageId : null;
const isActiveConversation = activeConversationId === conversationId;
const latestMessageId = isActiveConversation ? activeLatestMessageId : null;
const resolveTargetMessageId = useCallback(async (): Promise<string> => {
let selectedMessageId = getActiveLatestMessage()?.messageId ?? latestMessageId;
if (!selectedMessageId) {
await queryClient.fetchQuery(
[QueryKeys.messages, conversationId],
() => dataService.getMessagesByConvoId(conversationId),
{ staleTime: 0 },
);
selectedMessageId = getActiveLatestMessage()?.messageId ?? null;
}
if (!selectedMessageId) {
throw createShareTargetError('NO_MESSAGES');
}
const persistedMessages = await dataService.getMessageById(conversationId, selectedMessageId);
if (!persistedMessages.some((message) => message.messageId === selectedMessageId)) {
throw createShareTargetError('TARGET_MESSAGE_NOT_FOUND');
}
return selectedMessageId;
}, [conversationId, getActiveLatestMessage, latestMessageId, queryClient]);
const { data: share, isLoading } = useGetSharedLinkQuery(conversationId);
const shareId = share?.shareId ?? '';
@ -74,6 +108,7 @@ export default function ShareButton({
share={share}
conversationId={conversationId}
targetMessageId={latestMessageId ?? undefined}
resolveTargetMessageId={isActiveConversation ? resolveTargetMessageId : undefined}
showQR={showQR}
setShowQR={setShowQR}
sharedLink={sharedLink}

View file

@ -32,10 +32,51 @@ import { useHasAccess, useResourcePermissions, useLocalize } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { buildShareLinkUrl } from '~/utils';
type SharePublicationErrorCode = 'TARGET_MESSAGE_NOT_FOUND' | 'NO_MESSAGES';
const getSharePublicationErrorCode = (error: unknown): SharePublicationErrorCode | undefined => {
if (error == null || typeof error !== 'object') {
return undefined;
}
const directCode = 'code' in error ? error.code : undefined;
if (directCode === 'TARGET_MESSAGE_NOT_FOUND' || directCode === 'NO_MESSAGES') {
return directCode;
}
if (!('response' in error) || error.response == null || typeof error.response !== 'object') {
return undefined;
}
const data = 'data' in error.response ? error.response.data : undefined;
if (data == null || typeof data !== 'object' || !('code' in data)) {
return undefined;
}
return data.code === 'TARGET_MESSAGE_NOT_FOUND' || data.code === 'NO_MESSAGES'
? data.code
: undefined;
};
const publishWithTailRetry = async <T,>(
resolveTargetMessageId: () => Promise<string | undefined>,
publish: (targetMessageId?: string) => Promise<T>,
): Promise<T> => {
try {
return await publish(await resolveTargetMessageId());
} catch (error) {
const code = getSharePublicationErrorCode(error);
if (code !== 'TARGET_MESSAGE_NOT_FOUND' && code !== 'NO_MESSAGES') {
throw error;
}
}
return publish(await resolveTargetMessageId());
};
export default function SharedLinkButton({
share,
conversationId,
targetMessageId,
resolveTargetMessageId,
showQR,
setShowQR,
sharedLink,
@ -45,6 +86,7 @@ export default function SharedLinkButton({
share: TSharedLinkGetResponse | undefined;
conversationId: string;
targetMessageId?: string;
resolveTargetMessageId?: () => Promise<string>;
showQR: boolean;
setShowQR: (showQR: boolean) => void;
sharedLink: string;
@ -58,6 +100,7 @@ export default function SharedLinkButton({
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
const [refreshAnimationId, setRefreshAnimationId] = useState(0);
const [isPublishing, setIsPublishing] = useState(false);
const [canNativeShare, setCanNativeShare] = useState(false);
const [announcement, setAnnouncement] = useState('');
const shareId = share?.shareId ?? '';
@ -67,25 +110,9 @@ export default function SharedLinkButton({
setCanNativeShare(typeof navigator !== 'undefined' && typeof navigator.share === 'function');
}, []);
const { mutateAsync: mutate, isLoading: isCreateLoading } = useCreateSharedLinkMutation({
onError: () => {
showToast({
message: localize('com_ui_share_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
});
const { mutateAsync: mutate, isLoading: isCreateLoading } = useCreateSharedLinkMutation();
const { mutateAsync, isLoading: isUpdateLoading } = useUpdateSharedLinkMutation({
onError: () => {
showToast({
message: localize('com_ui_share_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
});
const { mutateAsync, isLoading: isUpdateLoading } = useUpdateSharedLinkMutation();
const deleteMutation = useDeleteSharedLinkMutation({
onSuccess: () => {
@ -110,13 +137,34 @@ export default function SharedLinkButton({
const generateShareLink = (shareId: string) => buildShareLinkUrl(shareId);
const showPublicationError = (error: unknown) => {
const code = getSharePublicationErrorCode(error);
let message = localize('com_ui_share_error');
if (code === 'TARGET_MESSAGE_NOT_FOUND') {
message = localize('com_ui_share_target_not_saved');
} else if (code === 'NO_MESSAGES') {
message = localize('com_ui_share_no_messages');
}
showToast({
message,
severity: NotificationSeverity.ERROR,
showIcon: true,
});
};
const resolvePublicationTarget = () =>
resolveTargetMessageId?.() ?? Promise.resolve(targetMessageId);
const updateSharedLink = async () => {
if (!shareId) {
return;
}
setIsPublishing(true);
try {
const updateShare = await mutateAsync({ shareId, targetMessageId, snapshotFiles });
const updateShare = await publishWithTailRetry(resolvePublicationTarget, (resolvedTargetId) =>
mutateAsync({ shareId, targetMessageId: resolvedTargetId, snapshotFiles }),
);
setRefreshAnimationId((animationId) => animationId + 1);
setSharedLink(generateShareLink(updateShare.shareId));
setShowUpdateDialog(false);
@ -126,15 +174,24 @@ export default function SharedLinkButton({
}, 1000);
} catch (error) {
console.error('Failed to update shared link:', error);
showPublicationError(error);
} finally {
setIsPublishing(false);
}
};
const createShareLink = async () => {
setIsPublishing(true);
try {
const share = await mutate({ conversationId, targetMessageId, snapshotFiles });
const share = await publishWithTailRetry(resolvePublicationTarget, (resolvedTargetId) =>
mutate({ conversationId, targetMessageId: resolvedTargetId, snapshotFiles }),
);
setSharedLink(generateShareLink(share.shareId));
} catch (error) {
console.error('Failed to create shared link:', error);
showPublicationError(error);
} finally {
setIsPublishing(false);
}
};
@ -207,13 +264,13 @@ export default function SharedLinkButton({
{!shareId && (
<Button
type="button"
disabled={isCreateLoading}
disabled={isCreateLoading || isPublishing}
variant="submit"
onClick={createShareLink}
className="ml-auto min-w-28"
>
{!isCreateLoading && localize('com_ui_create_link')}
{isCreateLoading && <Spinner className="size-4" />}
{!isCreateLoading && !isPublishing && localize('com_ui_create_link')}
{(isCreateLoading || isPublishing) && <Spinner className="size-4" />}
</Button>
)}
{shareId && (
@ -276,7 +333,7 @@ export default function SharedLinkButton({
variant="outline"
size="icon"
className="size-9 sm:size-10"
disabled={isUpdateLoading}
disabled={isUpdateLoading || isPublishing}
aria-label={localize('com_ui_update_shared_link')}
>
<RotateCw
@ -351,9 +408,9 @@ export default function SharedLinkButton({
type="button"
variant="submit"
onClick={updateSharedLink}
disabled={isUpdateLoading}
disabled={isUpdateLoading || isPublishing}
>
{isUpdateLoading && <Spinner className="size-4" />}
{(isUpdateLoading || isPublishing) && <Spinner className="size-4" />}
{localize('com_ui_update_shared_link')}
</Button>
</div>

View file

@ -1,6 +1,7 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import '@testing-library/jest-dom';
import type { MutableSnapshot } from 'recoil';
import ShareButton from '../ShareButton';
@ -17,6 +18,22 @@ let mockShare: {
};
const mockCopyLink = jest.fn(() => true);
const mockAnnouncePolite = jest.fn();
const mockGetMessagesByConvoId = jest.fn();
const mockGetMessageById = jest.fn();
let mockResolveTargetMessageId: (() => Promise<string>) | undefined;
let mockLatestMessageId: string | null = 'message-1';
jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider');
return {
...actual,
dataService: {
...actual.dataService,
getMessagesByConvoId: (...args: unknown[]) => mockGetMessagesByConvoId(...args),
getMessageById: (...args: unknown[]) => mockGetMessageById(...args),
},
};
});
jest.mock('librechat-data-provider/react-query', () => ({
useGetSharedLinkQuery: () => ({ data: mockShare, isLoading: false }),
@ -27,7 +44,9 @@ jest.mock('~/data-provider', () => ({
}));
jest.mock('~/hooks/Messages/useLatestMessage', () => ({
useLatestMessageId: () => 'message-1',
useLatestMessageId: () => mockLatestMessageId,
useGetLatestMessage: () => () =>
mockLatestMessageId == null ? null : { messageId: mockLatestMessageId },
}));
jest.mock('~/hooks', () => ({
@ -46,38 +65,51 @@ jest.mock('../SharedLinkButton', () => ({
setShowQR,
snapshotFiles,
targetMessageId,
resolveTargetMessageId,
}: {
showQR: boolean;
setShowQR: (show: boolean) => void;
snapshotFiles?: boolean;
targetMessageId?: string;
}) => (
<button
type="button"
data-testid="share-actions"
data-snapshot-files={String(snapshotFiles)}
data-target-message-id={String(targetMessageId)}
onClick={() => setShowQR(!showQR)}
>
{showQR ? 'com_ui_hide_qr' : 'com_ui_show_qr'}
</button>
),
resolveTargetMessageId?: () => Promise<string>;
}) => {
mockResolveTargetMessageId = resolveTargetMessageId;
return (
<button
type="button"
data-testid="share-actions"
data-snapshot-files={String(snapshotFiles)}
data-target-message-id={String(targetMessageId)}
onClick={() => setShowQR(!showQR)}
>
{showQR ? 'com_ui_hide_qr' : 'com_ui_show_qr'}
</button>
);
},
}));
const ACTIVE_CONVERSATION_ID = 'conversation-1';
const renderShareButton = (conversationId = ACTIVE_CONVERSATION_ID) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const initializeState = ({ set }: MutableSnapshot) => {
set(store.conversationByIndex(0), {
conversationId: ACTIVE_CONVERSATION_ID,
} as never);
};
return render(
<RecoilRoot initializeState={initializeState}>
<ShareButton conversationId={conversationId} open={true} onOpenChange={jest.fn()} />
</RecoilRoot>,
);
return {
queryClient,
...render(
<QueryClientProvider client={queryClient}>
<RecoilRoot initializeState={initializeState}>
<ShareButton conversationId={conversationId} open={true} onOpenChange={jest.fn()} />
</RecoilRoot>
</QueryClientProvider>,
),
};
};
describe('ShareButton', () => {
@ -90,6 +122,10 @@ describe('ShareButton', () => {
mockCopyLink.mockClear();
mockCopyLink.mockReturnValue(true);
mockAnnouncePolite.mockClear();
mockGetMessagesByConvoId.mockReset();
mockGetMessageById.mockReset();
mockLatestMessageId = 'message-1';
mockResolveTargetMessageId = undefined;
});
it('centers the active QR code and keeps details behind inline info controls', () => {
@ -150,21 +186,23 @@ describe('ShareButton', () => {
it('resets the file choice and link when the dialog moves to another conversation', () => {
mockShare = { success: true, shareId: 'share-1', snapshotFiles: false };
const { rerender } = renderShareButton();
const { rerender, queryClient } = renderShareButton();
expect(screen.getByRole('switch', { name: 'com_ui_share_files' })).not.toBeChecked();
mockShare = { success: true, shareId: null };
rerender(
<RecoilRoot
initializeState={({ set }: MutableSnapshot) => {
set(store.conversationByIndex(0), {
conversationId: ACTIVE_CONVERSATION_ID,
} as never);
}}
>
<ShareButton conversationId="conversation-2" open={true} onOpenChange={jest.fn()} />
</RecoilRoot>,
<QueryClientProvider client={queryClient}>
<RecoilRoot
initializeState={({ set }: MutableSnapshot) => {
set(store.conversationByIndex(0), {
conversationId: ACTIVE_CONVERSATION_ID,
} as never);
}}
>
<ShareButton conversationId="conversation-2" open={true} onOpenChange={jest.fn()} />
</RecoilRoot>
</QueryClientProvider>,
);
expect(screen.getByRole('switch', { name: 'com_ui_share_files' })).toBeChecked();
@ -181,6 +219,52 @@ describe('ShareButton', () => {
);
});
it('verifies the selected branch tail with a bounded persisted-message read', async () => {
mockGetMessageById.mockResolvedValue([{ messageId: 'message-1' }]);
renderShareButton();
await expect(mockResolveTargetMessageId?.()).resolves.toBe('message-1');
await expect(mockResolveTargetMessageId?.()).resolves.toBe('message-1');
expect(mockGetMessageById).toHaveBeenCalledTimes(2);
expect(mockGetMessageById).toHaveBeenCalledWith(ACTIVE_CONVERSATION_ID, 'message-1');
expect(mockGetMessagesByConvoId).not.toHaveBeenCalled();
});
it('rejects an unsaved selected tail instead of dropping the branch target', async () => {
mockGetMessageById.mockResolvedValue([]);
renderShareButton();
await expect(mockResolveTargetMessageId?.()).rejects.toMatchObject({
code: 'TARGET_MESSAGE_NOT_FOUND',
});
});
it('hydrates the message cache before deciding an initially unloaded chat is empty', async () => {
mockLatestMessageId = null;
mockGetMessagesByConvoId.mockImplementation(async () => {
mockLatestMessageId = 'persisted-message';
return [{ messageId: 'persisted-message' }];
});
mockGetMessageById.mockResolvedValue([{ messageId: 'persisted-message' }]);
renderShareButton();
await expect(mockResolveTargetMessageId?.()).resolves.toBe('persisted-message');
expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(ACTIVE_CONVERSATION_ID);
expect(mockGetMessageById).toHaveBeenCalledWith(ACTIVE_CONVERSATION_ID, 'persisted-message');
});
it('reports an empty chat only after checking persisted messages', async () => {
mockLatestMessageId = null;
mockGetMessagesByConvoId.mockResolvedValue([]);
renderShareButton();
await expect(mockResolveTargetMessageId?.()).rejects.toMatchObject({
code: 'NO_MESSAGES',
});
expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(ACTIVE_CONVERSATION_ID);
expect(mockGetMessageById).not.toHaveBeenCalled();
});
it('sends no target message when sharing a conversation other than the open one', () => {
renderShareButton('conversation-2');

View file

@ -139,6 +139,108 @@ describe('SharedLinkButton', () => {
expect(setSharedLink).toHaveBeenCalledWith(expect.stringContaining('/share/share-old'));
});
it('refetches the persisted tail and retries once when link creation misses it', async () => {
const resolveTargetMessageId = jest.fn().mockResolvedValue('message-1');
mockCreate
.mockRejectedValueOnce({
response: { data: { code: 'TARGET_MESSAGE_NOT_FOUND' } },
})
.mockResolvedValueOnce({ shareId: 'share-new' });
const setSharedLink = jest.fn();
renderActions({
share: { success: false, shareId: null },
resolveTargetMessageId,
setSharedLink,
});
fireEvent.click(screen.getByRole('button', { name: 'com_ui_create_link' }));
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(2));
expect(resolveTargetMessageId).toHaveBeenCalledTimes(2);
expect(mockCreate).toHaveBeenNthCalledWith(1, {
conversationId: 'conversation-1',
targetMessageId: 'message-1',
snapshotFiles: true,
});
expect(mockCreate).toHaveBeenNthCalledWith(2, {
conversationId: 'conversation-1',
targetMessageId: 'message-1',
snapshotFiles: true,
});
expect(setSharedLink).toHaveBeenCalledWith(expect.stringContaining('/share/share-new'));
expect(mockShowToast).not.toHaveBeenCalled();
});
it('does not publish another branch when the selected tail is still unsaved', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
const missingTail = Object.assign(new Error('missing tail'), {
code: 'TARGET_MESSAGE_NOT_FOUND',
});
const resolveTargetMessageId = jest.fn().mockRejectedValue(missingTail);
renderActions({
share: { success: false, shareId: null },
resolveTargetMessageId,
});
fireEvent.click(screen.getByRole('button', { name: 'com_ui_create_link' }));
await waitFor(() => expect(resolveTargetMessageId).toHaveBeenCalledTimes(2));
expect(mockCreate).not.toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith({
message: 'com_ui_share_target_not_saved',
severity: 'error',
showIcon: true,
});
consoleError.mockRestore();
});
it('retries a temporary empty read and publishes once persistence catches up', async () => {
const noMessages = Object.assign(new Error('no messages'), { code: 'NO_MESSAGES' });
const resolveTargetMessageId = jest
.fn()
.mockRejectedValueOnce(noMessages)
.mockResolvedValueOnce('message-1');
mockCreate.mockResolvedValue({ shareId: 'share-new' });
const setSharedLink = jest.fn();
renderActions({
share: { success: false, shareId: null },
resolveTargetMessageId,
setSharedLink,
});
fireEvent.click(screen.getByRole('button', { name: 'com_ui_create_link' }));
await waitFor(() => expect(resolveTargetMessageId).toHaveBeenCalledTimes(2));
expect(mockCreate).toHaveBeenCalledWith({
conversationId: 'conversation-1',
targetMessageId: 'message-1',
snapshotFiles: true,
});
expect(setSharedLink).toHaveBeenCalledWith(expect.stringContaining('/share/share-new'));
expect(mockShowToast).not.toHaveBeenCalled();
});
it('shows a precise error when messages are still absent after the retry', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
const noMessages = Object.assign(new Error('no messages'), { code: 'NO_MESSAGES' });
const resolveTargetMessageId = jest.fn().mockRejectedValue(noMessages);
renderActions({
share: { success: false, shareId: null },
resolveTargetMessageId,
});
fireEvent.click(screen.getByRole('button', { name: 'com_ui_create_link' }));
await waitFor(() => expect(resolveTargetMessageId).toHaveBeenCalledTimes(2));
expect(mockCreate).not.toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith({
message: 'com_ui_share_no_messages',
severity: 'error',
showIcon: true,
});
consoleError.mockRestore();
});
it('does not fake success when updating the link fails', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
mockUpdate.mockRejectedValue(new Error('update failed'));

View file

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react';
import { ShareHeader } from './ShareView';
import { fireEvent, render, screen } from '@testing-library/react';
import { ShareHeader, SharedLinkUnavailable } from './ShareView';
const defaultProps = {
title: 'Shared conversation',
@ -35,3 +35,35 @@ describe('ShareHeader', () => {
).not.toBeInTheDocument();
});
});
describe('SharedLinkUnavailable', () => {
it('lets the viewer retry a broken shared-link load', () => {
const onRetry = jest.fn();
render(
<SharedLinkUnavailable
message="Shared link not found"
retryLabel="Retry"
isRetrying={false}
onRetry={onRetry}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('disables retry while the shared link is refetching', () => {
render(
<SharedLinkUnavailable
message="Shared link not found"
retryLabel="Retry"
isRetrying={true}
onRetry={jest.fn()}
/>,
);
expect(screen.getByRole('button', { name: 'Retry' })).toBeDisabled();
});
});

View file

@ -4,7 +4,7 @@ import { buildTree } from 'librechat-data-provider';
import { useParams, useNavigate } from 'react-router-dom';
import { useRecoilState, useRecoilValue, useRecoilCallback } from 'recoil';
import { useGetSharedMessages } from 'librechat-data-provider/react-query';
import { CalendarDays, ExternalLink, Settings, MessageSquarePlus } from 'lucide-react';
import { CalendarDays, ExternalLink, RefreshCw, Settings, MessageSquarePlus } from 'lucide-react';
import {
Spinner,
Button,
@ -43,7 +43,7 @@ function SharedView() {
const { theme, setTheme } = useContext(ThemeContext);
const { shareId } = useParams();
const { data: config } = useGetSharedStartupConfig(shareId, { enabled: isAuthReady });
const { data, isLoading, refetch } = useGetSharedMessages(shareId ?? '', {
const { data, isLoading, isFetching, refetch } = useGetSharedMessages(shareId ?? '', {
enabled: isAuthReady,
});
const dataTree = data && buildTree({ messages: data.messages });
@ -203,9 +203,12 @@ function SharedView() {
);
} else {
content = (
<div className="flex h-screen items-center justify-center">
{localize('com_ui_shared_link_not_found')}
</div>
<SharedLinkUnavailable
message={localize('com_ui_shared_link_not_found')}
retryLabel={localize('com_ui_retry')}
isRetrying={isFetching}
onRetry={() => void refetch()}
/>
);
}
@ -250,6 +253,32 @@ function SharedView() {
);
}
export function SharedLinkUnavailable({
message,
retryLabel,
isRetrying,
onRetry,
}: {
message: string;
retryLabel: string;
isRetrying: boolean;
onRetry: () => void;
}) {
return (
<div className="flex h-screen flex-col items-center justify-center gap-4 px-4 text-center">
<p>{message}</p>
<Button type="button" variant="outline" onClick={onRetry} disabled={isRetrying}>
{isRetrying ? (
<Spinner className="size-4" />
) : (
<RefreshCw className="size-4" aria-hidden="true" />
)}
{retryLabel}
</Button>
</div>
);
}
function ShareTitle({ title }: { title?: string }) {
if (title == null || title === '') {
return null;

View file

@ -2041,7 +2041,9 @@
"com_ui_share_files_description": "Images and files in this conversation won't be visible to viewers unless this is enabled.",
"com_ui_share_files_update_note": "Choose your setting, then select Update link. The same URL will include the latest messages and file choice.",
"com_ui_share_link_to_chat": "Share link to chat",
"com_ui_share_no_messages": "This chat has no saved messages to share yet.",
"com_ui_share_qr_code_description": "QR code for sharing this conversation link",
"com_ui_share_target_not_saved": "The latest message has not finished saving. Try sharing again in a moment.",
"com_ui_share_update_message": "Your name and custom instructions stay private. Edits to shared messages appear right away; select Update link to include new messages without changing the URL.",
"com_ui_share_var": "Share {{0}}",
"com_ui_shared_link": "shared link",

View file

@ -16,6 +16,7 @@ import {
recordOpenIDUserLookup,
recordRedisOperation,
recordRumProxyRequest,
recordShareLinkRejection,
setGenerationJobsInFlight,
} from './metrics';
@ -414,6 +415,28 @@ describe('createMetrics', () => {
);
});
it('tracks bounded shared-link rejection outcomes', async () => {
const app = express();
process.env.METRICS_SECRET = 'test-secret';
const { metricsRouter } = createMetrics();
app.use('/metrics', metricsRouter);
recordShareLinkRejection('create', 'TARGET_MESSAGE_NOT_FOUND');
recordShareLinkRejection('update', 'NO_MESSAGES');
const response = await request(app)
.get('/metrics')
.set('Authorization', 'Bearer test-secret')
.expect(200);
expect(response.text).toMatch(
/share_link_rejections_total\{operation="create",code="TARGET_MESSAGE_NOT_FOUND"\} 1/,
);
expect(response.text).toMatch(
/share_link_rejections_total\{operation="update",code="NO_MESSAGES"\} 1/,
);
});
it('tracks mongoose query counts and latency by model and operation', async () => {
class FakeQuery {
model = { modelName: 'User' };

View file

@ -168,6 +168,8 @@ export type RumProxyResult =
| 'collector_5xx'
| 'collector_error'
| 'collector_timeout';
export type ShareLinkOperation = 'create' | 'update';
export type ShareLinkRejectionCode = 'TARGET_MESSAGE_NOT_FOUND' | 'NO_MESSAGES';
export type RedisClient = 'ioredis' | 'keyv';
export type RedisOperationStatus = 'success' | 'error';
@ -235,6 +237,14 @@ let rumProxyMetrics: RumProxyMetrics = {
recordRequest: () => undefined,
};
type ShareLinkMetrics = {
recordRejection: (operation: ShareLinkOperation, code: ShareLinkRejectionCode) => void;
};
let shareLinkMetrics: ShareLinkMetrics = {
recordRejection: () => undefined,
};
type RedisOperationMetrics = {
recordOperation: (
client: RedisClient,
@ -270,6 +280,9 @@ const resetMetricRecorders = (): void => {
rumProxyMetrics = {
recordRequest: () => undefined,
};
shareLinkMetrics = {
recordRejection: () => undefined,
};
redisOperationMetrics = {
recordOperation: () => undefined,
};
@ -328,6 +341,13 @@ export function recordRumProxyRequest(endpoint: RumProxyEndpoint, result: RumPro
rumProxyMetrics.recordRequest(endpoint, result);
}
export function recordShareLinkRejection(
operation: ShareLinkOperation,
code: ShareLinkRejectionCode,
): void {
shareLinkMetrics.recordRejection(operation, code);
}
export function recordRedisOperation(
client: RedisClient,
useCase: string,
@ -643,6 +663,13 @@ export function createMetrics(options: MetricsOptions = {}): PrometheusMetrics {
registers: [registry],
});
const shareLinkRejections = new Counter({
name: 'share_link_rejections_total',
help: 'Shared link publication rejections by operation and bounded domain code',
labelNames: ['operation', 'code'] as const,
registers: [registry],
});
const redisOperations = new Counter({
name: 'redis_operations_total',
help: 'Logical Redis operations by client, use case, operation, and status',
@ -750,6 +777,10 @@ export function createMetrics(options: MetricsOptions = {}): PrometheusMetrics {
recordRequest: (endpoint, result) => rumProxyRequests.inc({ endpoint, result }),
};
shareLinkMetrics = {
recordRejection: (operation, code) => shareLinkRejections.inc({ operation, code }),
};
redisOperationMetrics = {
recordOperation: (client, useCase, operation, status, durationSeconds) => {
const labels = { client, use_case: useCase, operation, status };

View file

@ -1002,6 +1002,10 @@ export function getMessagesByConvoId(conversationId: string): Promise<s.TMessage
return request.get(endpoints.messages({ conversationId }));
}
export function getMessageById(conversationId: string, messageId: string): Promise<s.TMessage[]> {
return request.get(endpoints.messages({ conversationId, messageId }));
}
export function getParentSubagents(parentConversationId: string): Promise<t.ParentSubagentIndex> {
return request.get(endpoints.parentSubagents(parentConversationId));
}

View file

@ -10,6 +10,7 @@ import {
type ShareMethods,
type SharedLinkContentSnapshot,
} from './share';
import logger from '~/config/winston';
describe('Share Methods', () => {
let mongoServer: MongoMemoryServer;
@ -1521,6 +1522,30 @@ describe('Share Methods', () => {
expect(await SharedLink.findOne({ shareId })).not.toBeNull();
});
test('does not error-log expected refresh target rejections', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
const shareId = `share_${nanoid()}`;
const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger);
await SharedLink.create({ shareId, conversationId, user: userId, messages: [] });
await Message.create({
messageId: `msg_${nanoid()}`,
conversationId,
user: userId,
text: 'Current message',
isCreatedByUser: true,
});
try {
await expect(
shareMethods.updateSharedLink(userId, shareId, 'missing-message'),
).rejects.toMatchObject({ code: 'TARGET_MESSAGE_NOT_FOUND' });
expect(errorSpy).not.toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
}
});
test('should only update with messages from the same user', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const otherUserId = new mongoose.Types.ObjectId().toString();

View file

@ -22,6 +22,8 @@ class ShareServiceError extends Error {
}
}
const EXPECTED_SHARE_REJECTION_CODES = new Set(['TARGET_MESSAGE_NOT_FOUND', 'NO_MESSAGES']);
type ShareOrder = Pick<t.ISharedLink, '_id' | 'createdAt'>;
const isEarlierShare = (candidate: ShareOrder, subject: ShareOrder): boolean => {
@ -1368,11 +1370,13 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
if (preflightFailed) {
throw error;
}
logger.error('[updateSharedLink] Error updating shared link', {
error: error instanceof Error ? error.message : 'Unknown error',
user,
shareId,
});
if (!(error instanceof ShareServiceError && EXPECTED_SHARE_REJECTION_CODES.has(error.code))) {
logger.error('[updateSharedLink] Error updating shared link', {
error: error instanceof Error ? error.message : 'Unknown error',
user,
shareId,
});
}
throw new ShareServiceError(
error instanceof ShareServiceError ? error.message : 'Error updating shared link',
error instanceof ShareServiceError ? error.code : 'SHARE_UPDATE_ERROR',