mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
🏷️ 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
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:
parent
9fbea04d46
commit
3191f6975a
2 changed files with 118 additions and 5 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue