From 3191f6975a28550f394bfc2d8bc0ab6b941514f6 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 1 Aug 2026 09:01:13 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20fix:=20Skip=20Title=20G?= =?UTF-8?q?eneration=20for=20Preempt-Incomplete=20Turns=20(#14571)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/request.resumeMetadata.spec.js | 108 ++++++++++++++++++ api/server/controllers/agents/request.js | 15 ++- 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index 66aec8b9d1..124ae0368c 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -3091,4 +3091,112 @@ describe('ResumableAgentController resume metadata', () => { expect(mockGenerationJobManager.createJob).not.toHaveBeenCalled(); expect(mockGenerationJobManager.releaseGeneration).not.toHaveBeenCalled(); }); + + describe('preempt-incomplete title gating', () => { + const { Constants } = require('librechat-data-provider'); + const { resolveTitleTiming } = require('@librechat/api'); + + /** An empty preempt boundary ends the turn truncated — the response is + * persisted `unfinished`, so it must follow the abort title contract. */ + const preemptIncompleteRun = { + getPreemptStats: () => ({ emptyBoundaries: 1 }), + getHaltReason: () => 'preempt_incomplete', + }; + + const runFirstTurn = async ({ run } = {}) => { + let signalFinished; + const finished = new Promise((resolve) => { + signalFinished = resolve; + }); + mockGenerationJobManager.finishTerminalJob.mockImplementation(async () => signalFinished()); + + let titleSignal; + const addTitle = jest.fn(async (_req, options) => { + titleSignal = options?.signal; + }); + + const client = { + options: {}, + savedMessageIds: new Set(), + skipSaveUserMessage: false, + ...(run && { run }), + sendMessage: jest.fn(async (_text, options) => { + const userMessage = { + messageId: 'user-msg', + parentMessageId: Constants.NO_PARENT, + conversationId: options.conversationId, + text: 'First message', + }; + options.onStart(userMessage, 'response-msg'); + return { + messageId: 'response-msg', + parentMessageId: 'user-msg', + conversationId: options.conversationId, + content: [{ type: 'text', text: 'Truncated answer' }], + databasePromise: Promise.resolve({ + conversation: { conversationId: options.conversationId, title: null }, + }), + }; + }), + }; + const req = { + user: { id: 'user-123' }, + body: { + text: 'First message', + messageId: 'user-msg', + parentMessageId: Constants.NO_PARENT, + conversationId: 'new', + endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } }, + }, + config: {}, + }; + + await AgentController( + req, + createResumableResponse(), + jest.fn(), + jest.fn().mockResolvedValue({ client }), + addTitle, + ); + await finished; + await nextTick(); + await nextTick(); + + return { addTitle, getTitleSignal: () => titleSignal }; + }; + + it('skips deferred title generation when an empty preempt boundary truncates the first turn', async () => { + resolveTitleTiming.mockReturnValueOnce('final'); + + const { addTitle } = await runFirstTurn({ run: preemptIncompleteRun }); + + expect(addTitle).not.toHaveBeenCalled(); + }); + + it('still generates a deferred title for a completed first turn', async () => { + resolveTitleTiming.mockReturnValueOnce('final'); + + const { addTitle } = await runFirstTurn(); + + expect(addTitle).toHaveBeenCalledTimes(1); + expect(addTitle).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ response: expect.anything() }), + ); + }); + + it('cancels an in-flight immediate title when the turn ends preempt-incomplete', async () => { + const { addTitle, getTitleSignal } = await runFirstTurn({ run: preemptIncompleteRun }); + + expect(addTitle).toHaveBeenCalledTimes(1); + expect(getTitleSignal().aborted).toBe(true); + }); + + it('lets an immediate title proceed for a completed first turn', async () => { + const { addTitle, getTitleSignal } = await runFirstTurn(); + + expect(addTitle).toHaveBeenCalledTimes(1); + expect(getTitleSignal().aborted).toBe(false); + }); + }); }); diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 37d043ad20..e0aa68f8d4 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -1562,7 +1562,11 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit } const shouldGenerateTitle = - addTitle && parentMessageId === Constants.NO_PARENT && isNewConvo && !terminalWasAborted; + addTitle && + parentMessageId === Constants.NO_PARENT && + isNewConvo && + !terminalWasAborted && + !preemptIncomplete; // Save user message BEFORE sending final event to avoid race condition // where client refetch happens before database is updated @@ -1616,10 +1620,11 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit ); } - // If the user stopped this turn, cancel the title BEFORE unblocking its - // persistence wait — otherwise resolving `convoReady` lets the title task - // resume and save before the later abort runs. - if (terminalWasAborted) { + // If the user stopped this turn — or an empty preempt boundary truncated + // it, which persists under the same honest `unfinished` contract — cancel + // the title BEFORE unblocking its persistence wait; otherwise resolving + // `convoReady` lets the title task resume and save before the later abort runs. + if (terminalWasAborted || preemptIncomplete) { titleAbortController.abort(); } else { job.abortController.signal.removeEventListener('abort', abortTitleOnJobAbort);