LibreChat/api/server/services/Endpoints/agents/title.test.js
Danny Avila 1612dba353
🏷️ fix: Preserve Generated Conversation Title on Stop (#13568)
Immediate title generation discarded an already-generated title when the
user stopped the turn, both in the backend (skipped saveConvo) and the
frontend (rolled back the streamed title), leaving the chat as "Untitled"
in the interim and "New Chat" after refresh.

Split the title abort into two signals: `signal` still cancels an in-flight
title model call on Stop, while a new `discardSignal` discards an
already-generated title only when the stream is superseded by a newer run
or the turn fails. A plain user Stop now persists and keeps the title.
The frontend no longer rolls back a real, already-applied title on an
aborted final event.
2026-06-07 08:59:05 -04:00

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