/** 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(); }); });