mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🔄 feat: Continue Shared Conversations as Personal Copies (#13714)
Adds a "Continue this chat" button to the shared conversation view that forks the shared conversation into a new conversation owned by the viewer and opens it to continue (issue #13001). - POST /api/share/:shareId/fork, gated by requireJwtAuth, the fork rate limiters, and the canAccessSharedLink ACL (view access = fork access). - forkSharedConversation clones from the anonymized getSharedMessages payload, so only share-visible data is copied. - Strips file ids from cloned files/attachments so a fork grants no more file access than viewing the read-only share, and honors the global shared-file kill switch via the snapshotFiles option. - Reduces the clone to the viewer's active branch, located by its index in the shared payload (shared ids are re-anonymized per request and createdAt can collide, while the payload order is stable). - Resolves config/retention, persists, and reads back under the requesting user's tenant, not the share owner's; canAccessSharedLink also falls back to a system-wide share lookup so cross-tenant public shares resolve (ACL still enforced under the share's own tenant). - Resolves a usable endpoint/model from the viewer's models config instead of hard-coding OpenAI, so deployments without OpenAI can send the first message. - Routes the fork's 401s (logged-out or cold-loaded viewers) through login, including when the refresh itself is rejected for a stale session. - Hides the Temporary Chat toggle once a conversation has a real id, and portals the share-settings theme/language dropdowns above the dialog. Rebased onto dev; collapses the share-fork feature and its review fixes into a single commit.
This commit is contained in:
parent
b84e26671e
commit
61016e328a
19 changed files with 1122 additions and 56 deletions
|
|
@ -60,8 +60,83 @@ async function resolveImportDefaultModel({ endpoint, requestUserId, userRole })
|
|||
return FALLBACK_MODEL_BY_ENDPOINT[endpoint] ?? openAISettings.model.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preferred endpoint order for conversations cloned without a known source
|
||||
* endpoint. OpenAI is first so deployments that expose it keep prior behavior;
|
||||
* any other configured endpoint is still selected when these are unavailable.
|
||||
*/
|
||||
const DEFAULT_ENDPOINT_PREFERENCE = [
|
||||
EModelEndpoint.openAI,
|
||||
EModelEndpoint.anthropic,
|
||||
EModelEndpoint.google,
|
||||
EModelEndpoint.azureOpenAI,
|
||||
EModelEndpoint.bedrock,
|
||||
];
|
||||
|
||||
/**
|
||||
* Endpoints excluded as fork targets because they are stateful: each
|
||||
* conversation needs an assistant_id and thread_id that a cloned conversation
|
||||
* never creates, so the assistants chat controller rejects the first follow-up
|
||||
* ("Missing thread_id for existing conversation"). A fork must land on a
|
||||
* stateless chat endpoint. These can still surface in the runtime models config
|
||||
* (e.g. a deployment exposing only assistant models), so filter them out.
|
||||
*/
|
||||
const EXCLUDED_FORK_ENDPOINTS = new Set([
|
||||
EModelEndpoint.assistants,
|
||||
EModelEndpoint.azureAssistants,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Resolves an endpoint and model the requesting user can actually use, for
|
||||
* conversations cloned without a known source endpoint (shared forks, whose
|
||||
* original endpoint is stripped from the sanitized payload). Picks the first
|
||||
* preferred endpoint exposing models, then any other configured endpoint
|
||||
* (excluding stateful assistant endpoints, which a fork cannot resume), so a
|
||||
* deployment that doesn't expose OpenAI doesn't produce a conversation whose
|
||||
* first message is rejected by model validation. Falls back to OpenAI defaults
|
||||
* only when the runtime models config is empty or unavailable.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {string} args.requestUserId - The id of the requesting user.
|
||||
* @param {string} [args.userRole] - The role of the requesting user.
|
||||
* @returns {Promise<{ endpoint: string, model: string }>} A usable endpoint and model.
|
||||
*/
|
||||
async function resolveImportDefaultEndpoint({ requestUserId, userRole }) {
|
||||
try {
|
||||
const modelsConfig = await getModelsConfig({
|
||||
user: { id: requestUserId, role: userRole, tenantId: getTenantId() },
|
||||
});
|
||||
if (modelsConfig) {
|
||||
const orderedEndpoints = [
|
||||
...DEFAULT_ENDPOINT_PREFERENCE,
|
||||
...Object.keys(modelsConfig).filter(
|
||||
(endpoint) => !DEFAULT_ENDPOINT_PREFERENCE.includes(endpoint),
|
||||
),
|
||||
];
|
||||
for (const endpoint of orderedEndpoints) {
|
||||
if (EXCLUDED_FORK_ENDPOINTS.has(endpoint)) {
|
||||
continue;
|
||||
}
|
||||
const model = pickFirstConfiguredModel(endpoint, modelsConfig);
|
||||
if (model) {
|
||||
return { endpoint, model };
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[import] Failed to resolve a default endpoint from modelsConfig: ${error.message}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
model: FALLBACK_MODEL_BY_ENDPOINT[EModelEndpoint.openAI] ?? openAISettings.model.default,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FALLBACK_MODEL_BY_ENDPOINT,
|
||||
pickFirstConfiguredModel,
|
||||
resolveImportDefaultModel,
|
||||
resolveImportDefaultEndpoint,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ jest.mock('@librechat/data-schemas', () => {
|
|||
const {
|
||||
pickFirstConfiguredModel,
|
||||
resolveImportDefaultModel,
|
||||
resolveImportDefaultEndpoint,
|
||||
FALLBACK_MODEL_BY_ENDPOINT,
|
||||
} = require('./defaults');
|
||||
|
||||
|
|
@ -120,3 +121,85 @@ describe('resolveImportDefaultModel', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveImportDefaultEndpoint', () => {
|
||||
it('prefers OpenAI when it exposes models', async () => {
|
||||
mockGetModelsConfig.mockResolvedValueOnce({
|
||||
[EModelEndpoint.openAI]: ['gpt-4o'],
|
||||
[EModelEndpoint.anthropic]: ['claude-opus-4-7'],
|
||||
});
|
||||
|
||||
const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
|
||||
|
||||
expect(result).toEqual({ endpoint: EModelEndpoint.openAI, model: 'gpt-4o' });
|
||||
});
|
||||
|
||||
it('falls back to another configured endpoint when OpenAI is unavailable', async () => {
|
||||
mockGetModelsConfig.mockResolvedValueOnce({
|
||||
[EModelEndpoint.openAI]: [],
|
||||
[EModelEndpoint.anthropic]: ['claude-opus-4-7'],
|
||||
});
|
||||
|
||||
const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
|
||||
|
||||
expect(result).toEqual({ endpoint: EModelEndpoint.anthropic, model: 'claude-opus-4-7' });
|
||||
});
|
||||
|
||||
it('selects a custom endpoint when no preferred endpoint has models', async () => {
|
||||
mockGetModelsConfig.mockResolvedValueOnce({
|
||||
'my-custom': ['custom-model-1'],
|
||||
});
|
||||
|
||||
const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
|
||||
|
||||
expect(result).toEqual({ endpoint: 'my-custom', model: 'custom-model-1' });
|
||||
});
|
||||
|
||||
it('skips stateful assistant endpoints and selects a stateless one', async () => {
|
||||
mockGetModelsConfig.mockResolvedValueOnce({
|
||||
[EModelEndpoint.assistants]: ['gpt-4o'],
|
||||
[EModelEndpoint.azureAssistants]: ['gpt-4o'],
|
||||
'my-custom': ['custom-model-1'],
|
||||
});
|
||||
|
||||
const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
|
||||
|
||||
expect(result).toEqual({ endpoint: 'my-custom', model: 'custom-model-1' });
|
||||
});
|
||||
|
||||
it('falls back to OpenAI defaults when only assistant endpoints expose models', async () => {
|
||||
mockGetModelsConfig.mockResolvedValueOnce({
|
||||
[EModelEndpoint.assistants]: ['gpt-4o'],
|
||||
[EModelEndpoint.azureAssistants]: ['gpt-4o'],
|
||||
});
|
||||
|
||||
const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
|
||||
|
||||
expect(result).toEqual({
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
model: openAISettings.model.default,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to OpenAI defaults when the models config is empty', async () => {
|
||||
mockGetModelsConfig.mockResolvedValueOnce({});
|
||||
|
||||
const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
|
||||
|
||||
expect(result).toEqual({
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
model: openAISettings.model.default,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to OpenAI defaults when getModelsConfig rejects', async () => {
|
||||
mockGetModelsConfig.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
|
||||
|
||||
expect(result).toEqual({
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
model: openAISettings.model.default,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
const { v4: uuidv4 } = require('uuid');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { logger, tenantStorage } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint, Constants, ForkOptions } = require('librechat-data-provider');
|
||||
const { getConvo, getMessages, getSharedMessages } = require('~/models');
|
||||
const { createImportBatchBuilder } = require('./importBatchBuilder');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { resolveImportDefaultEndpoint } = require('./defaults');
|
||||
const BaseClient = require('~/app/clients/BaseClient');
|
||||
const { getConvo, getMessages } = require('~/models');
|
||||
|
||||
/**
|
||||
* Helper function to clone messages with proper parent-child relationships and timestamps
|
||||
|
|
@ -352,6 +354,146 @@ function splitAtTargetLevel(messages, targetMessageId) {
|
|||
return filteredMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips file identifiers from a shared message's `files` and `attachments`.
|
||||
* A shared fork is owned by the requesting user, but the underlying file records
|
||||
* still belong to the original sharer. Persisting their `file_id`s would let the
|
||||
* agents file-resend path collect them on the next turn and call `getUserCodeFiles`,
|
||||
* which looks them up by `file_id` with no ownership filter, rehydrating the
|
||||
* sharer's files into the viewer's run. Dropping the ids keeps a fork's file
|
||||
* access no broader than viewing the read-only share, while leaving render-only
|
||||
* metadata (e.g. `filepath`, `toolCallId`) intact.
|
||||
* @param {TMessage} message - The shared message to sanitize.
|
||||
* @returns {TMessage} The message with file identifiers removed.
|
||||
*/
|
||||
function stripSharedFileIds(message) {
|
||||
const sanitized = { ...message };
|
||||
if (Array.isArray(sanitized.files)) {
|
||||
sanitized.files = sanitized.files.map(({ file_id: _fileId, ...file }) => file);
|
||||
}
|
||||
if (Array.isArray(sanitized.attachments)) {
|
||||
sanitized.attachments = sanitized.attachments.map(
|
||||
({ file_id: _fileId, ...attachment }) => attachment,
|
||||
);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forks a shared (sanitized) conversation into a fresh conversation owned by the requesting user.
|
||||
* Only the anonymized, allowlisted message fields returned by `getSharedMessages` are cloned,
|
||||
* so no private data from the original owner can leak into the new conversation.
|
||||
* @param {object} params - The parameters for forking the shared conversation.
|
||||
* @param {string} params.shareId - The ID of the shared link to fork from.
|
||||
* @param {string} [params.shareResourceId] - The SharedLink resource ID set by `canAccessSharedLink`.
|
||||
* @param {string} params.requestUserId - The ID of the user making the request.
|
||||
* @param {string} [params.userRole] - The role of the requesting user, used to resolve the default model.
|
||||
* @param {string} [params.userTenantId] - Tenant of the requesting user. `canAccessSharedLink` runs this handler under the share owner's tenant so the share resolves, so the copy must be persisted (and its config/retention resolved) under the requesting user's tenant or it would be invisible (404) when they open it normally.
|
||||
* @param {number} [params.targetMessageIndex] - Index, within the shared payload, of the message at the tip of the branch the viewer has active. When set, only the direct path to that message is cloned so the fork continues the branch that was actually shown rather than the newest sibling. An index is used (not id or `createdAt`) because shared ids are re-anonymized per request while `getSharedMessages` returns a deterministic, stable order, so the same index resolves to the same message on the server.
|
||||
* @param {boolean} [params.snapshotFiles] - When `false`, file/attachment metadata is omitted from the cloned messages, mirroring the GET share route so the global shared-file kill switch is honored.
|
||||
* @param {(userId: string, interfaceConfig?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance.
|
||||
* @param {(options: object) => Promise<object>} [params.loadAppConfig] - Resolves the app config; injectable for tests. Called inside the requesting user's tenant context so retention policy is read from the viewer's tenant, not the share owner's.
|
||||
* @returns {Promise<TForkConvoResponse | null>} The new conversation and messages, or null when the share is missing or empty.
|
||||
*/
|
||||
async function forkSharedConversation({
|
||||
shareId,
|
||||
shareResourceId,
|
||||
requestUserId,
|
||||
userRole,
|
||||
userTenantId,
|
||||
targetMessageIndex,
|
||||
snapshotFiles,
|
||||
builderFactory = createImportBatchBuilder,
|
||||
loadAppConfig = getAppConfig,
|
||||
}) {
|
||||
// Mirror the GET share route: when the shared-file snapshot is globally
|
||||
// disabled, omit file/attachment metadata so a fork can't persist filenames
|
||||
// or share file URLs into the new conversation while file serving is off.
|
||||
const share = await getSharedMessages(shareId, shareResourceId, { snapshotFiles });
|
||||
if (!share?.messages?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared payload includes sibling branches. Reduce to the direct path of
|
||||
* the viewer's active message so the fork continues exactly the branch that
|
||||
* was shown; without this the default branch selection lands on the newest
|
||||
* sibling. The active tip is located by its index in the shared payload, which
|
||||
* `getSharedMessages` returns in a deterministic order (stored ref-array order)
|
||||
* — unlike ids (re-anonymized per request) or `createdAt` (can collide). Falls
|
||||
* back to the full set when the index is absent or out of range.
|
||||
*/
|
||||
let sourceMessages = share.messages;
|
||||
if (
|
||||
Number.isInteger(targetMessageIndex) &&
|
||||
targetMessageIndex >= 0 &&
|
||||
targetMessageIndex < share.messages.length
|
||||
) {
|
||||
const targetMessage = share.messages[targetMessageIndex];
|
||||
const directPath = BaseClient.getMessagesForConversation({
|
||||
messages: share.messages,
|
||||
parentMessageId: targetMessage.messageId,
|
||||
});
|
||||
if (directPath.length > 0) {
|
||||
sourceMessages = directPath;
|
||||
}
|
||||
}
|
||||
|
||||
const messageIds = new Set(sourceMessages.map((message) => message.messageId));
|
||||
const messagesToClone = sourceMessages.map(({ model: _model, ...message }) =>
|
||||
stripSharedFileIds({
|
||||
...message,
|
||||
parentMessageId:
|
||||
message.parentMessageId != null && messageIds.has(message.parentMessageId)
|
||||
? message.parentMessageId
|
||||
: Constants.NO_PARENT,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Persist and read back under the requesting user's tenant rather than the
|
||||
* share owner's. The read above runs in the share owner's tenant (set by
|
||||
* `canAccessSharedLink`); writing the copy there would leave it invisible to
|
||||
* the user under their normal tenant context (the new conversation would 404
|
||||
* when they navigate to it). Switching to the user's tenant only affects this
|
||||
* deployment when tenant isolation is enabled; otherwise it is a no-op.
|
||||
*/
|
||||
return tenantStorage.run({ tenantId: userTenantId, userId: requestUserId }, async () => {
|
||||
// Resolve config inside the viewer's tenant so retention (e.g. all-data
|
||||
// expiry) reflects the requesting user's tenant, not the share owner's.
|
||||
const appConfig = await loadAppConfig({
|
||||
role: userRole,
|
||||
userId: requestUserId,
|
||||
tenantId: userTenantId,
|
||||
});
|
||||
// The shared payload strips the original endpoint, so resolve one the viewer
|
||||
// can actually use; hard-coding OpenAI breaks the first follow-up message on
|
||||
// deployments that don't expose it.
|
||||
const { endpoint, model } = await resolveImportDefaultEndpoint({ requestUserId, userRole });
|
||||
const importBatchBuilder = builderFactory(requestUserId, appConfig?.interfaceConfig);
|
||||
importBatchBuilder.startConversation(endpoint);
|
||||
|
||||
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
|
||||
|
||||
const result = importBatchBuilder.finishConversation(share.title, new Date(), {}, model);
|
||||
await importBatchBuilder.saveBatch();
|
||||
logger.debug(
|
||||
`user: ${requestUserId} | New conversation "${result.conversation.title}" forked from share ID ${shareId}`,
|
||||
);
|
||||
|
||||
const conversation = await getConvo(requestUserId, result.conversation.conversationId);
|
||||
const messages = await getMessages({
|
||||
user: requestUserId,
|
||||
conversationId: conversation.conversationId,
|
||||
});
|
||||
|
||||
return {
|
||||
conversation,
|
||||
messages,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates a conversation and all its messages.
|
||||
* @param {object} params - The parameters for duplicating the conversation.
|
||||
|
|
@ -404,6 +546,7 @@ module.exports = {
|
|||
forkConversation,
|
||||
splitAtTargetLevel,
|
||||
duplicateConversation,
|
||||
forkSharedConversation,
|
||||
getAllMessagesUpToParent,
|
||||
getMessagesUpToTargetLevel,
|
||||
cloneMessagesWithTimestamps,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,15 @@ jest.mock('~/models', () => ({
|
|||
getMessages: jest.fn(),
|
||||
bulkSaveMessages: jest.fn(),
|
||||
bulkIncrementTagCounts: jest.fn(),
|
||||
getSharedMessages: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/controllers/ModelController', () => ({
|
||||
getModelsConfig: jest.fn().mockResolvedValue({ openAI: ['gpt-test'] }),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: jest.fn().mockResolvedValue({ interfaceConfig: {} }),
|
||||
}));
|
||||
|
||||
let mockIdCounter = 0;
|
||||
|
|
@ -21,6 +30,7 @@ jest.mock('uuid', () => {
|
|||
const {
|
||||
forkConversation,
|
||||
duplicateConversation,
|
||||
forkSharedConversation,
|
||||
splitAtTargetLevel,
|
||||
getAllMessagesUpToParent,
|
||||
getMessagesUpToTargetLevel,
|
||||
|
|
@ -32,7 +42,9 @@ const {
|
|||
bulkSaveConvos,
|
||||
getMessages,
|
||||
bulkSaveMessages,
|
||||
getSharedMessages,
|
||||
} = require('~/models');
|
||||
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
||||
const { createImportBatchBuilder } = require('./importBatchBuilder');
|
||||
const BaseClient = require('~/app/clients/BaseClient');
|
||||
|
||||
|
|
@ -301,6 +313,359 @@ describe('duplicateConversation', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('forkSharedConversation', () => {
|
||||
const mockSharedMessages = [
|
||||
{
|
||||
messageId: 'msg_a',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
text: 'Shared root',
|
||||
isCreatedByUser: true,
|
||||
createdAt: '2021-01-01',
|
||||
},
|
||||
{
|
||||
messageId: 'msg_b',
|
||||
parentMessageId: 'msg_a',
|
||||
text: 'Shared reply',
|
||||
isCreatedByUser: false,
|
||||
createdAt: '2021-01-02',
|
||||
},
|
||||
];
|
||||
|
||||
const mockShare = {
|
||||
shareId: 'share123',
|
||||
conversationId: 'convo_anon',
|
||||
title: 'Shared Title',
|
||||
messages: mockSharedMessages,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockIdCounter = 0;
|
||||
getSharedMessages.mockResolvedValue(mockShare);
|
||||
getConvo.mockResolvedValue(mockConversation);
|
||||
getMessages.mockResolvedValue(mockSharedMessages);
|
||||
bulkSaveConvos.mockResolvedValue(null);
|
||||
bulkSaveMessages.mockResolvedValue(null);
|
||||
bulkIncrementTagCounts.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
test('should clone shared messages into a conversation owned by the requesting user', async () => {
|
||||
const result = await forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
shareResourceId: 'resource123',
|
||||
requestUserId: 'user1',
|
||||
});
|
||||
|
||||
expect(getSharedMessages).toHaveBeenCalledWith('share123', 'resource123', {
|
||||
snapshotFiles: undefined,
|
||||
});
|
||||
|
||||
const savedMessages = bulkSaveMessages.mock.calls[0][0];
|
||||
expect(savedMessages).toHaveLength(2);
|
||||
const [root, reply] = savedMessages;
|
||||
expect(root).toMatchObject({
|
||||
text: 'Shared root',
|
||||
user: 'user1',
|
||||
endpoint: 'openAI',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
});
|
||||
expect(reply).toMatchObject({
|
||||
text: 'Shared reply',
|
||||
user: 'user1',
|
||||
parentMessageId: root.messageId,
|
||||
});
|
||||
expect(root.messageId).not.toBe('msg_a');
|
||||
expect(reply.messageId).not.toBe('msg_b');
|
||||
|
||||
const savedConvos = bulkSaveConvos.mock.calls[0][0];
|
||||
expect(savedConvos[0]).toMatchObject({
|
||||
user: 'user1',
|
||||
title: 'Shared Title',
|
||||
endpoint: 'openAI',
|
||||
model: 'gpt-test',
|
||||
});
|
||||
|
||||
expect(getConvo).toHaveBeenCalledWith('user1', savedConvos[0].conversationId);
|
||||
expect(result).toMatchObject({ conversation: mockConversation, messages: mockSharedMessages });
|
||||
});
|
||||
|
||||
test('should use an available endpoint when the deployment does not expose OpenAI', async () => {
|
||||
getModelsConfig.mockResolvedValueOnce({ anthropic: ['claude-test'] });
|
||||
|
||||
await forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
shareResourceId: 'resource123',
|
||||
requestUserId: 'user1',
|
||||
});
|
||||
|
||||
const savedConvos = bulkSaveConvos.mock.calls[0][0];
|
||||
expect(savedConvos[0]).toMatchObject({ endpoint: 'anthropic', model: 'claude-test' });
|
||||
|
||||
const savedMessages = bulkSaveMessages.mock.calls[0][0];
|
||||
expect(savedMessages.every((message) => message.endpoint === 'anthropic')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return null when the share is not found', async () => {
|
||||
getSharedMessages.mockResolvedValue(null);
|
||||
|
||||
const result = await forkSharedConversation({
|
||||
shareId: 'missing',
|
||||
requestUserId: 'user1',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(bulkSaveMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return null when the share has no messages', async () => {
|
||||
getSharedMessages.mockResolvedValue({ ...mockShare, messages: [] });
|
||||
|
||||
const result = await forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
requestUserId: 'user1',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(bulkSaveMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should normalize orphaned parentMessageId references to NO_PARENT', async () => {
|
||||
getSharedMessages.mockResolvedValue({
|
||||
...mockShare,
|
||||
messages: [
|
||||
{
|
||||
messageId: 'msg_orphan',
|
||||
parentMessageId: 'msg_deleted',
|
||||
text: 'Orphaned message',
|
||||
createdAt: '2021-01-01',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
requestUserId: 'user1',
|
||||
});
|
||||
|
||||
const savedMessages = bulkSaveMessages.mock.calls[0][0];
|
||||
expect(savedMessages[0].parentMessageId).toBe(Constants.NO_PARENT);
|
||||
});
|
||||
|
||||
test('should forward snapshotFiles to getSharedMessages so the kill switch is honored', async () => {
|
||||
await forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
shareResourceId: 'resource123',
|
||||
requestUserId: 'user1',
|
||||
snapshotFiles: false,
|
||||
});
|
||||
|
||||
expect(getSharedMessages).toHaveBeenCalledWith('share123', 'resource123', {
|
||||
snapshotFiles: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('should strip anonymized model identifiers from cloned messages', async () => {
|
||||
getSharedMessages.mockResolvedValue({
|
||||
...mockShare,
|
||||
messages: [
|
||||
{
|
||||
messageId: 'msg_a',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
text: 'Assistant message',
|
||||
model: 'a_anon123',
|
||||
createdAt: '2021-01-01',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
requestUserId: 'user1',
|
||||
});
|
||||
|
||||
const savedMessages = bulkSaveMessages.mock.calls[0][0];
|
||||
expect(savedMessages[0].model).not.toBe('a_anon123');
|
||||
});
|
||||
|
||||
test('should strip file_id from cloned files and attachments', async () => {
|
||||
getSharedMessages.mockResolvedValue({
|
||||
...mockShare,
|
||||
messages: [
|
||||
{
|
||||
messageId: 'msg_a',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
text: 'Message with files',
|
||||
isCreatedByUser: true,
|
||||
createdAt: '2021-01-01',
|
||||
files: [{ file_id: 'owner-file-1', filepath: '/images/owner/a.png' }],
|
||||
attachments: [
|
||||
{ file_id: 'owner-file-2', toolCallId: 'tool_1', filepath: '/images/owner/b.png' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
requestUserId: 'user1',
|
||||
});
|
||||
|
||||
const savedMessages = bulkSaveMessages.mock.calls[0][0];
|
||||
const [message] = savedMessages;
|
||||
expect(message.files[0]).not.toHaveProperty('file_id');
|
||||
expect(message.attachments[0]).not.toHaveProperty('file_id');
|
||||
// Render-only metadata is preserved
|
||||
expect(message.files[0].filepath).toBe('/images/owner/a.png');
|
||||
expect(message.attachments[0].toolCallId).toBe('tool_1');
|
||||
});
|
||||
|
||||
test('should resolve interfaceConfig from the app config and pass it to the builder', async () => {
|
||||
const interfaceConfig = { retentionMode: 'all', retention: { days: 30 } };
|
||||
const loadAppConfig = jest.fn().mockResolvedValue({ interfaceConfig });
|
||||
const builderFactory = jest.fn((userId, config) => createImportBatchBuilder(userId, config));
|
||||
|
||||
await forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
requestUserId: 'user1',
|
||||
userRole: 'USER',
|
||||
userTenantId: 'tenant-viewer',
|
||||
loadAppConfig,
|
||||
builderFactory,
|
||||
});
|
||||
|
||||
expect(loadAppConfig).toHaveBeenCalledWith({
|
||||
role: 'USER',
|
||||
userId: 'user1',
|
||||
tenantId: 'tenant-viewer',
|
||||
});
|
||||
expect(builderFactory).toHaveBeenCalledWith('user1', interfaceConfig);
|
||||
});
|
||||
|
||||
test('should resolve the app config under the requesting user tenant', async () => {
|
||||
const { tenantStorage, getTenantId } = require('@librechat/data-schemas');
|
||||
let tenantDuringConfigLoad;
|
||||
const loadAppConfig = jest.fn(async () => {
|
||||
tenantDuringConfigLoad = getTenantId();
|
||||
return { interfaceConfig: {} };
|
||||
});
|
||||
|
||||
await tenantStorage.run({ tenantId: 'tenant-share-owner' }, () =>
|
||||
forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
requestUserId: 'user1',
|
||||
userTenantId: 'tenant-viewer',
|
||||
loadAppConfig,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(tenantDuringConfigLoad).toBe('tenant-viewer');
|
||||
});
|
||||
|
||||
test('should clone only the active branch path when targetMessageIndex is provided', async () => {
|
||||
getSharedMessages.mockResolvedValue({
|
||||
...mockShare,
|
||||
messages: [
|
||||
{
|
||||
messageId: 'msg_root',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
text: 'Root',
|
||||
createdAt: '2021-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
messageId: 'msg_branch_a',
|
||||
parentMessageId: 'msg_root',
|
||||
text: 'Branch A (shared)',
|
||||
createdAt: '2021-01-02T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
messageId: 'msg_branch_b',
|
||||
parentMessageId: 'msg_root',
|
||||
text: 'Branch B (newer sibling)',
|
||||
createdAt: '2021-01-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Index 1 = the "Branch A" tip the viewer had active.
|
||||
await forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
requestUserId: 'user1',
|
||||
targetMessageIndex: 1,
|
||||
});
|
||||
|
||||
const savedTexts = bulkSaveMessages.mock.calls[0][0].map((message) => message.text);
|
||||
expect(savedTexts).toEqual(['Root', 'Branch A (shared)']);
|
||||
expect(savedTexts).not.toContain('Branch B (newer sibling)');
|
||||
});
|
||||
|
||||
test('should select the correct branch even when siblings share a createdAt', async () => {
|
||||
getSharedMessages.mockResolvedValue({
|
||||
...mockShare,
|
||||
messages: [
|
||||
{
|
||||
messageId: 'msg_root',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
text: 'Root',
|
||||
createdAt: '2021-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
messageId: 'msg_sib_a',
|
||||
parentMessageId: 'msg_root',
|
||||
text: 'Sibling A',
|
||||
createdAt: '2021-01-02T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
messageId: 'msg_sib_b',
|
||||
parentMessageId: 'msg_root',
|
||||
text: 'Sibling B (same timestamp)',
|
||||
createdAt: '2021-01-02T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Index 2 unambiguously targets Sibling B despite the shared createdAt.
|
||||
await forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
requestUserId: 'user1',
|
||||
targetMessageIndex: 2,
|
||||
});
|
||||
|
||||
const savedTexts = bulkSaveMessages.mock.calls[0][0].map((message) => message.text);
|
||||
expect(savedTexts).toEqual(['Root', 'Sibling B (same timestamp)']);
|
||||
expect(savedTexts).not.toContain('Sibling A');
|
||||
});
|
||||
|
||||
test('should fall back to the full set when targetMessageIndex is out of range', async () => {
|
||||
await forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
requestUserId: 'user1',
|
||||
targetMessageIndex: 999,
|
||||
});
|
||||
|
||||
expect(bulkSaveMessages.mock.calls[0][0]).toHaveLength(mockSharedMessages.length);
|
||||
});
|
||||
|
||||
test('should persist under the requesting user tenant, not the share tenant', async () => {
|
||||
const { tenantStorage, getTenantId } = require('@librechat/data-schemas');
|
||||
let tenantDuringSave;
|
||||
bulkSaveConvos.mockImplementation(async () => {
|
||||
tenantDuringSave = getTenantId();
|
||||
});
|
||||
|
||||
// Simulate the handler running inside the share owner's tenant context
|
||||
// (as `canAccessSharedLink` does) and ensure the write switches to the viewer's.
|
||||
await tenantStorage.run({ tenantId: 'tenant-share-owner' }, () =>
|
||||
forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
requestUserId: 'user1',
|
||||
userTenantId: 'tenant-viewer',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(tenantDuringSave).toBe('tenant-viewer');
|
||||
});
|
||||
});
|
||||
|
||||
const mockMessagesComplex = [
|
||||
{ messageId: '7', parentMessageId: Constants.NO_PARENT, text: 'Message 7' },
|
||||
{ messageId: '8', parentMessageId: Constants.NO_PARENT, text: 'Message 8' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue