🏷️ fix: Skip Title Generation for Preempt-Incomplete Turns (#14571)
Some checks failed
Publish `@librechat/client` to NPM / pack (push) Has been cancelled
Publish `librechat-data-provider` to NPM / pack (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / pack (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
GitNexus Index / index (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Helm Chart Tags / Ignore non-main push (push) Has been cancelled
Sync Helm Chart Tags / Sync chart tags (push) Has been cancelled
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled

This commit is contained in:
Danny Avila 2026-08-01 09:01:13 -04:00 committed by GitHub
parent 9fbea04d46
commit 3191f6975a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 118 additions and 5 deletions

View file

@ -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);
});
});
});

View file

@ -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);