Files
wehub-resource-sync e115934061
Publish `@librechat/data-schemas` to NPM / pack (push) Failing after 8s
Publish `@librechat/client` to NPM / pack (push) Failing after 0s
GitNexus Index / index (push) Failing after 1s
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been skipped
Sync Helm Chart Tags / Sync chart tags (push) Failing after 2s
Publish `librechat-data-provider` to NPM / pack (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Failing after 0s
Sync Helm Chart Tags / Ignore non-main push (push) Has been skipped
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Failing after 1s
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:08:12 +08:00

314 lines
9.2 KiB
JavaScript

/** Backing store so `get` reflects prior `set`/`delete` — addTitle reads the cache
* back to avoid clobbering a replacement stream's title on abort. */
const mockCacheStore = new Map();
const mockCache = {
get: jest.fn((key) => mockCacheStore.get(key)),
set: jest.fn((key, value) => mockCacheStore.set(key, value)),
delete: jest.fn((key) => mockCacheStore.delete(key)),
};
const mockSaveConvo = jest.fn();
jest.mock('@librechat/api', () => ({
isEnabled: (val) => val === true || val === 'true',
sanitizeTitle: (title) => title,
}));
jest.mock('@librechat/data-schemas', () => ({
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
}));
jest.mock('librechat-data-provider', () => ({
CacheKeys: { GEN_TITLE: 'GEN_TITLE' },
}));
jest.mock('~/cache/getLogStores', () => jest.fn(() => mockCache));
jest.mock('~/models', () => ({
saveConvo: (...args) => mockSaveConvo(...args),
}));
const addTitle = require('./title');
const flush = () => new Promise((resolve) => setImmediate(resolve));
const makeClient = (title = 'Generated Title') => ({
options: { titleConvo: true },
titleConvo: jest.fn().mockResolvedValue(title),
});
const makeReq = () => ({ user: { id: 'user-1' }, body: {}, config: {} });
describe('agents addTitle', () => {
beforeEach(() => {
jest.clearAllMocks();
mockCacheStore.clear();
});
it('uses the explicit conversationId for the cache key and saveConvo (immediate mode)', async () => {
const client = makeClient('My Title');
await addTitle(makeReq(), {
text: 'hello',
client,
conversationId: 'cid-immediate',
immediate: true,
convoReady: Promise.resolve(),
});
expect(mockCache.set).toHaveBeenCalledWith(
'user-1-cid-immediate',
'My Title',
expect.any(Number),
);
expect(mockSaveConvo).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ conversationId: 'cid-immediate', title: 'My Title' }),
expect.objectContaining({ noUpsert: true }),
);
});
it('passes immediate:true through to client.titleConvo', async () => {
const client = makeClient();
await addTitle(makeReq(), {
text: 'hello',
client,
conversationId: 'cid',
immediate: true,
convoReady: Promise.resolve(),
});
expect(client.titleConvo).toHaveBeenCalledWith(expect.objectContaining({ immediate: true }));
});
it('falls back to response.conversationId in legacy (final) mode', async () => {
const client = makeClient('Legacy Title');
await addTitle(makeReq(), {
text: 'hi',
client,
response: { conversationId: 'resp-cid' },
});
expect(mockCache.set).toHaveBeenCalledWith(
'user-1-resp-cid',
'Legacy Title',
expect.any(Number),
);
expect(mockSaveConvo).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ conversationId: 'resp-cid', title: 'Legacy Title' }),
expect.objectContaining({ noUpsert: true }),
);
expect(client.titleConvo).toHaveBeenCalledWith(expect.objectContaining({ immediate: false }));
});
it('caches the title immediately but defers saveConvo until convoReady resolves', async () => {
const client = makeClient('Deferred Title');
let resolveConvo;
const convoReady = new Promise((resolve) => {
resolveConvo = resolve;
});
const pending = addTitle(makeReq(), {
text: 'hello',
client,
conversationId: 'cid-defer',
immediate: true,
convoReady,
});
await flush();
// Title is cached for the live UI, but persistence waits for the row to exist.
expect(mockCache.set).toHaveBeenCalledWith(
'user-1-cid-defer',
'Deferred Title',
expect.any(Number),
);
expect(mockSaveConvo).not.toHaveBeenCalled();
resolveConvo();
await pending;
expect(mockSaveConvo).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ conversationId: 'cid-defer', title: 'Deferred Title' }),
expect.objectContaining({ noUpsert: true }),
);
});
it('notifies when the title is cached before waiting for convoReady', async () => {
const order = [];
const client = makeClient('Streamed Title');
const onTitleGenerated = jest.fn(async () => {
order.push('title-event');
});
let resolveConvo;
const convoReady = new Promise((resolve) => {
resolveConvo = resolve;
});
mockCache.set.mockImplementationOnce((key, value) => {
order.push('cache');
mockCacheStore.set(key, value);
});
mockSaveConvo.mockImplementationOnce(async () => {
order.push('save');
});
const pending = addTitle(makeReq(), {
text: 'hello',
client,
conversationId: 'cid-stream',
immediate: true,
convoReady,
onTitleGenerated,
});
await flush();
expect(onTitleGenerated).toHaveBeenCalledWith({
conversationId: 'cid-stream',
title: 'Streamed Title',
});
expect(order).toEqual(['cache', 'title-event']);
expect(mockSaveConvo).not.toHaveBeenCalled();
resolveConvo();
await pending;
expect(order).toEqual(['cache', 'title-event', 'save']);
});
it('skips generation when the endpoint disables titleConvo', async () => {
const client = makeClient();
client.options.titleConvo = false;
await addTitle(makeReq(), { text: 'hi', client, conversationId: 'cid', immediate: true });
expect(client.titleConvo).not.toHaveBeenCalled();
expect(mockSaveConvo).not.toHaveBeenCalled();
});
it('skips generation for temporary conversations', async () => {
const client = makeClient();
const req = makeReq();
req.body.isTemporary = true;
await addTitle(req, { text: 'hi', client, conversationId: 'cid', immediate: true });
expect(client.titleConvo).not.toHaveBeenCalled();
expect(mockSaveConvo).not.toHaveBeenCalled();
});
it('skips generation when neither conversationId nor response is provided', async () => {
const client = makeClient();
await addTitle(makeReq(), { text: 'hi', client });
expect(client.titleConvo).not.toHaveBeenCalled();
expect(mockCache.set).not.toHaveBeenCalled();
expect(mockSaveConvo).not.toHaveBeenCalled();
});
it('propagates the abort signal to the title model call', async () => {
const client = makeClient();
const ac = new AbortController();
ac.abort();
await addTitle(makeReq(), {
text: 'hi',
client,
conversationId: 'cid',
immediate: true,
convoReady: Promise.resolve(),
signal: ac.signal,
});
const { abortController } = client.titleConvo.mock.calls[0][0];
expect(abortController.signal.aborted).toBe(true);
});
it('discards the title without persisting when the stream is superseded', async () => {
const client = makeClient();
const ac = new AbortController();
const onTitleGenerated = jest.fn();
ac.abort();
await addTitle(makeReq(), {
text: 'hi',
client,
conversationId: 'cid',
immediate: true,
convoReady: Promise.resolve(),
signal: ac.signal,
discardSignal: ac.signal,
onTitleGenerated,
});
expect(onTitleGenerated).not.toHaveBeenCalled();
expect(mockSaveConvo).not.toHaveBeenCalled();
expect(mockCache.delete).toHaveBeenCalledWith('user-1-cid');
});
it("does not delete a replacement stream's cached title when superseded", async () => {
const client = makeClient('Stale Title');
const ac = new AbortController();
ac.abort();
// Simulate a replacement stream having cached its own (newer) title under the
// shared `userId-conversationId` key by the time this stale task re-reads it.
mockCache.get.mockImplementationOnce(() => 'Newer Title');
await addTitle(makeReq(), {
text: 'hi',
client,
conversationId: 'cid',
immediate: true,
convoReady: Promise.resolve(),
signal: ac.signal,
discardSignal: ac.signal,
});
expect(mockCache.delete).not.toHaveBeenCalled();
expect(mockSaveConvo).not.toHaveBeenCalled();
});
it('persists a title generated before a user Stop (signal aborted, not superseded)', async () => {
const client = makeClient('Kept Title');
// `signal` represents a user Stop; no `discardSignal` since the stream is not
// superseded. The title finishes generating and is emitted before the Stop.
const ac = new AbortController();
const onTitleGenerated = jest.fn();
let resolveConvo;
const convoReady = new Promise((resolve) => {
resolveConvo = resolve;
});
const pending = addTitle(makeReq(), {
text: 'hi',
client,
conversationId: 'cid',
immediate: true,
convoReady,
signal: ac.signal,
onTitleGenerated,
});
await flush();
expect(onTitleGenerated).toHaveBeenCalledWith({ conversationId: 'cid', title: 'Kept Title' });
// User stops mid-response, then the conversation row is persisted.
ac.abort();
resolveConvo();
await pending;
expect(mockSaveConvo).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ conversationId: 'cid', title: 'Kept Title' }),
expect.objectContaining({ noUpsert: true }),
);
expect(mockCache.delete).not.toHaveBeenCalled();
});
});