mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-24 16:36:14 +00:00
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
258 lines
7.6 KiB
JavaScript
258 lines
7.6 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 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();
|
|
});
|
|
});
|