feat: Immediate Conversation Title Generation (#13395)
Some checks are pending
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
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
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
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run

*  feat: Immediate Conversation Title Generation

Generate conversation titles as soon as the request is made (in parallel
with the response, from the user's first message) as the new default,
fixing the #13318 race where a transient /gen_title 404 left new chats
stuck on "New Chat".

- Add per-endpoint `titleTiming` ('immediate' | 'final') to baseEndpointSchema;
  `endpoints.all` acts as the global default, unset = immediate. Resolve via
  a new `resolveTitleTiming` helper (`all` takes precedence).
- Fire title generation in parallel with `sendMessage`; `titleConvo` waits
  (bounded, abortable) for the agent run and titles from the user input only.
  Persist after the conversation row exists; defer `disposeClient` until the
  title settles.
- Expose `titleGenerationTiming` via startup config; `useTitleGeneration`
  fetches eagerly in immediate mode with a bounded 404 retry and never treats
  a transient 404 as final. Skip title queueing for temporary conversations.
- Supersedes #13329 while incorporating its bounded 404-retry.

* 🩹 fix: Address Copilot review findings on title timing

- Guard against an undefined conversationId in addTitle (skip + warn) so the
  gen_title cache key can't collide as `userId-undefined` and saveConvo is
  never called without a conversationId.
- Gate the title `useQueries` on `enabled` so no /gen_title request fires while
  unauthenticated (e.g. after logout) even if the module queue holds IDs.
- Drop the stale `conversationId` param from the titleConvo JSDoc.
- Add a regression test for the undefined-conversationId guard.

* 🧵 fix: Harden immediate-title edge cases from codex review

- Cancel in-flight immediate title generation when the request aborts: thread
  job.abortController.signal through addTitle so pressing Stop on a new chat
  neither consumes the title model nor surfaces a title for a cancelled turn.
- Preserve a locally-applied title when the final SSE event's conversation
  carries no title yet (built before the title was saved), so long immediate-mode
  responses no longer revert the chat to "New Chat" until reload.
- Guarantee one full post-completion gen_title fetch cycle before giving up, so a
  `final`-mode title (generated only after the stream ends) is still fetched under
  a global `immediate` default instead of being stranded.
- Add regression tests for the abort propagation and the undefined-conversationId guard.

* 🔁 fix: Correct title abort, post-completion refetch, and replacement ordering

Follow-up to codex review of the immediate-title fixes:

- Use a dedicated title AbortController instead of `job.abortController`. The
  latter is also aborted by `completeJob` on *successful* completion, which
  cancelled any title slower than a short response. The title is now cancelled
  only on a real user Stop or when the stream is replaced; a completed-then-
  aborted title is discarded (no save, cache cleared) rather than persisted.
- Reset (not remove) the post-completion title query: `resetQueries` refetches
  the mounted observer with a fresh retry budget, whereas `removeQueries` left it
  stuck in its error state, so the promised post-completion cycle never ran.
- Run the job-replacement check before resolving `convoReady`, and on a replaced
  stream cancel/discard the stale title so a discarded prompt can't persist a title.

* 🧷 fix: Tighten title abort ordering and endpoint-level timing resolution

Follow-up to codex review:

- Abort the title controller before resolving `convoReady` on a stopped turn, so
  the title task can't resume and persist before the later abort.
- Cancel the title and unblock its waits on ANY send failure (not just user
  aborts): a preflight/quota failure before the run exists otherwise hangs
  `_waitForRun`, deferring client disposal until the 45s title timeout.
- Resolve `titleTiming` for custom endpoints via `getCustomEndpointConfig`
  (their config lives under `endpoints.custom[]`, not `endpoints[endpoint]`).
- Derive the startup `titleGenerationTiming` via `resolveTitleTiming` for the
  agents endpoint so an endpoint-level `final` (without `endpoints.all`) is honored
  client-side instead of defaulting to immediate and burning eager gen_title polls.

* 🪢 fix: Per-agent title timing and safer abort/replacement handling

Follow-up to codex review:

- Resolve `titleTiming` from the agent's actual endpoint after initialization, so a
  per-endpoint `final` override on a custom/provider endpoint backing an (ephemeral)
  agent is honored instead of always using the `agents` endpoint's value.
- Don't preserve a locally-fetched title on a stopped (unfinished) turn: the server
  cancels and discards that title, so keeping it client-side would diverge from
  server state and leave the stopped chat titled until reload.
- On abort/replacement, only delete the cached title if it still holds THIS task's
  value — a replacement stream shares the `userId-conversationId` key and may have
  already cached its own valid title that must not be removed.

* 🪞 fix: Mirror AgentClient title-config resolution for titleTiming

Per maintainer guidance, keep titleTiming resolution identical to how
`AgentClient#titleConvo` already resolves the endpoint config — `endpoints.all`
is the intended global override and the agent's actual provider endpoint is used:

- Resolve via `endpoints.all ?? endpoints[endpoint] ?? getProviderConfig(endpoint)
  .customEndpointConfig` (was using `getCustomEndpointConfig` directly). Going
  through `getProviderConfig` picks up its case-insensitive fallback for normalized
  provider names (e.g. `openrouter` → `OpenRouter`), so a custom endpoint's
  `titleTiming` is honored like its other title settings.
- Add `titleTiming` to the Azure endpoint schema `.pick()` so
  `endpoints.azureOpenAI.titleTiming` is no longer silently stripped by Zod.

Note: per-endpoint title settings being skipped when `endpoints.all` is present is
the existing, intended global-override behavior — not changed here.

* 🧪 test: Cover useTitleGeneration effect logic (integration)

Adds a deterministic white-box integration test that drives the real hook's
React effects with a controllable react-query surface, locking down the
stateful decisions that previously had no coverage:

- immediate mode fetches a queued conversation while its stream is still active
- final mode gates until the stream completes, then becomes eligible
- success applies the fetched title to the conversation caches
- a 404 while active defers (removeQueries) instead of giving up
- a 404 after completion forces a fresh fetch via resetQueries (post-completion remount)

* feat: Stream immediate title events

* style: Format title SSE handler

* test: Preserve data-provider exports in OAuth mock

* test: Isolate OAuth route API mock

* test: Keep OAuth callback factory capture

* fix: Replay streamed title events on resume

* fix: Honor agents title timing precedence

* style: Format title timing fixes
This commit is contained in:
Danny Avila 2026-06-02 16:40:57 -04:00 committed by GitHub
parent b45e4aeae5
commit 2ef7bdfbc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1437 additions and 52 deletions

View file

@ -5,9 +5,40 @@ const getLogStores = require('~/cache/getLogStores');
const { saveConvo } = require('~/models');
/**
* Add title to conversation in a way that avoids memory retention
* Add title to conversation in a way that avoids memory retention.
*
* @param {ServerRequest} req
* @param {Object} params
* @param {string} params.text - The user's first message.
* @param {TMessage} [params.response] - The assistant response (legacy/`final` timing only).
* @param {AgentClient} params.client
* @param {string} [params.conversationId] - Required for `immediate` timing, where
* `response` is not yet available; falls back to `response.conversationId`.
* @param {boolean} [params.immediate] - When true, the title is generated in parallel
* with the response (from the user's first message) and persisted to the conversation
* only after `convoReady` resolves (the conversation row must exist for `noUpsert`).
* @param {Promise<void>} [params.convoReady] - Resolves once the conversation has been
* persisted; awaited before saving the title in `immediate` mode.
* @param {AbortSignal} [params.signal] - When aborted (e.g. the user stops an
* immediate-mode generation), cancels the in-flight title model call so a
* cancelled turn neither consumes the title model nor surfaces a title.
* @param {(params: { conversationId: string, title: string }) => Promise<void>|void} [params.onTitleGenerated]
* Called after the title is cached and before persistence waits for the
* conversation row. Used by live streams to push the title immediately.
*/
const addTitle = async (req, { text, response, client }) => {
const addTitle = async (
req,
{
text,
response,
client,
conversationId,
immediate = false,
convoReady,
signal,
onTitleGenerated,
},
) => {
const { TITLE_CONVO = true } = process.env ?? {};
if (!isEnabled(TITLE_CONVO)) {
return;
@ -22,8 +53,14 @@ const addTitle = async (req, { text, response, client }) => {
return;
}
const convoId = conversationId ?? response?.conversationId;
if (!convoId) {
logger.warn('[addTitle] Missing conversationId; skipping title generation');
return;
}
const titleCache = getLogStores(CacheKeys.GEN_TITLE);
const key = `${req.user.id}-${response.conversationId}`;
const key = `${req.user.id}-${convoId}`;
/** @type {NodeJS.Timeout} */
let timeoutId;
try {
@ -35,12 +72,22 @@ const addTitle = async (req, { text, response, client }) => {
let titlePromise;
let abortController = new AbortController();
/** Propagate a request abort (Stop) to the title generation so a cancelled
* turn does not consume the title model or surface a title. */
if (signal) {
if (signal.aborted) {
abortController.abort();
} else {
signal.addEventListener('abort', () => abortController.abort(), { once: true });
}
}
if (client && typeof client.titleConvo === 'function') {
titlePromise = Promise.race([
client
.titleConvo({
text,
abortController,
immediate,
})
.catch((error) => {
logger.error('Client title error:', error);
@ -65,6 +112,37 @@ const addTitle = async (req, { text, response, client }) => {
}
await titleCache.set(key, title, 120000);
if (!signal?.aborted && typeof onTitleGenerated === 'function') {
try {
await onTitleGenerated({ conversationId: convoId, title });
} catch (error) {
logger.error('Error emitting generated title:', error);
}
}
/** In immediate mode the title is generated in parallel with the response,
* so the conversation row may not exist yet. `saveConvo` with `noUpsert`
* is a silent no-op when the row is missing, which would drop the title
* from the database (the cache above still serves the live UI). Wait for
* the controller to signal the conversation has been persisted. */
if (convoReady) {
await convoReady;
}
if (signal?.aborted) {
// The turn was stopped, or this stream was replaced, after the title had
// already been generated — discard it instead of persisting a title for a
// cancelled/discarded response. Only clear the cache if it still holds THIS
// task's title: a replacement stream shares the `userId-conversationId` key
// and may have already cached its own (valid) title that we must not remove.
const cached = await titleCache.get(key);
if (cached === title) {
await titleCache.delete(key);
}
return;
}
await saveConvo(
{
userId: req?.user?.id,
@ -72,7 +150,7 @@ const addTitle = async (req, { text, response, client }) => {
interfaceConfig: req?.config?.interfaceConfig,
},
{
conversationId: response.conversationId,
conversationId: convoId,
title,
},
{ context: 'api/server/services/Endpoints/agents/title.js', noUpsert: true },

View file

@ -0,0 +1,258 @@
/** 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 an aborted request signal and discards the title without persisting', 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,
onTitleGenerated,
});
const { abortController } = client.titleConvo.mock.calls[0][0];
expect(abortController.signal.aborted).toBe(true);
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 aborted", 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,
});
expect(mockCache.delete).not.toHaveBeenCalled();
expect(mockSaveConvo).not.toHaveBeenCalled();
});
});