📻 fix: Replay MCP OAuth Prompts for Coalesced Connections (#13565)

* fix: Replay MCP OAuth URL for Joined Connections

* chore: Sort MCP OAuth Imports

* test: Restore MCP OAuth Registry Spies

* fix: Replay pending MCP OAuth prompts

* fix: Replay MCP OAuth on Stream Resume

* fix: Preserve MCP OAuth Replay Context

* chore: Format MCP OAuth Replay Context

* test: Expect MCP OAuth Replay Expiry

* fix: Render pending MCP OAuth prompts

* chore: Clean MCP OAuth Replay Type Narrowing

* fix: Stabilize new MCP OAuth chats

* fix: Re-emit cached MCP OAuth prompts

* fix: Replay pending OAuth for selected MCP tools

* fix: Avoid stalling pending MCP OAuth replay

* test: Clean MCP OAuth review findings

* test: Restore MCP OAuth registry spy

* fix: Resolve OAuth Typecheck Regressions

* fix: Harden MCP OAuth replay edge cases

* test: Cover MCP OAuth joined prompt expiry

* test: Mark joined OAuth replay fixture

* test: Use OAuth fixture for joined replay expiry

* fix: Anchor resumed MCP OAuth prompts

* fix: Seed resumable turn metadata before MCP init

* test: Format resume metadata regression

* fix: Prioritize resumable stream routes

* fix: Preserve MCP OAuth resume message tree

* test: Fix MCP OAuth Resume Test Types

* fix: Replay MCP OAuth Regenerate Prompts

* fix: Skip OAuth-only Abort Persistence

* fix: Stabilize OAuth Resume Replay

* fix: Target Non-Tail Regenerate Responses

* fix: Scope Regenerate Step Updates

* fix: Clean Up OAuth Abort State

* fix: Preserve Regenerate Branch Siblings

* fix: Preserve OAuth Resume Branch State

* fix: Preserve OAuth Branch Resume State

* chore: Sort OAuth Resume Imports

* fix: Address OAuth Resume Review Findings

* test: Fix Abort Fixture Typing
This commit is contained in:
Danny Avila 2026-06-07 10:45:54 -04:00 • committed by GitHub
parent 6950448d03
commit cb1d536874
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 4837 additions and 299 deletions

View file

@ -0,0 +1,197 @@
const mockLogger = {
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
};
const mockGenerationJobManager = {
createJob: jest.fn(),
emitError: jest.fn(),
completeJob: jest.fn(),
getResumeState: jest.fn(),
updateMetadata: jest.fn(),
};
const mockCheckAndIncrementPendingRequest = jest.fn();
const mockDecrementPendingRequest = jest.fn();
const mockFilterPersistableAbortContent = jest.fn((content) =>
content.filter((part) => part?.type !== 'tool_call'),
);
const mockGetConvo = jest.fn();
const mockSaveMessage = jest.fn();
jest.mock('@librechat/data-schemas', () => ({
logger: mockLogger,
}));
jest.mock('@librechat/api', () => ({
sendEvent: jest.fn(),
getViolationInfo: jest.fn(),
buildMessageFiles: jest.fn(() => []),
resolveTitleTiming: jest.fn(() => 'immediate'),
GenerationJobManager: mockGenerationJobManager,
filterPersistableAbortContent: (...args) => mockFilterPersistableAbortContent(...args),
decrementPendingRequest: (...args) => mockDecrementPendingRequest(...args),
sanitizeMessageForTransmit: jest.fn((message) => message),
checkAndIncrementPendingRequest: (...args) => mockCheckAndIncrementPendingRequest(...args),
}));
jest.mock('~/server/cleanup', () => ({
disposeClient: jest.fn(),
clientRegistry: null,
requestDataMap: {
set: jest.fn(),
},
}));
jest.mock('~/server/middleware', () => ({
handleAbortError: jest.fn(() => Promise.resolve()),
}));
jest.mock('~/cache', () => ({
logViolation: jest.fn(),
}));
jest.mock('~/models', () => ({
saveMessage: (...args) => mockSaveMessage(...args),
getConvo: (...args) => mockGetConvo(...args),
}));
const AgentController = require('../request');
describe('ResumableAgentController resume metadata', () => {
beforeEach(() => {
jest.clearAllMocks();
mockCheckAndIncrementPendingRequest.mockResolvedValue({ allowed: true });
mockDecrementPendingRequest.mockResolvedValue(undefined);
mockGetConvo.mockResolvedValue({ createdAt: '2026-06-07T00:00:00.000Z' });
mockGenerationJobManager.createJob.mockResolvedValue({
createdAt: 1000,
readyPromise: Promise.resolve(),
abortController: new AbortController(),
emitter: { on: jest.fn() },
});
mockGenerationJobManager.getResumeState.mockResolvedValue(null);
mockGenerationJobManager.updateMetadata.mockResolvedValue(undefined);
mockGenerationJobManager.emitError.mockResolvedValue(undefined);
mockSaveMessage.mockResolvedValue({});
});
it('stores the in-flight turn before MCP initialization can emit OAuth', async () => {
const conversationId = 'conversation-123';
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Check Google Workspace availability.',
messageId: 'follow-up-user',
parentMessageId: 'original-response',
conversationId,
endpointOption: {
endpoint: 'agents',
modelOptions: { model: 'gpt-3.5-turbo' },
},
},
config: {},
};
const res = {
headersSent: true,
json: jest.fn(() => {
res.headersSent = true;
}),
status: jest.fn(() => res),
};
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(conversationId, {
conversationId,
responseMessageId: 'follow-up-user_',
userMessage: {
messageId: 'follow-up-user',
parentMessageId: 'original-response',
conversationId,
text: 'Check Google Workspace availability.',
},
});
expect(mockGenerationJobManager.updateMetadata.mock.invocationCallOrder[0]).toBeLessThan(
initializeClient.mock.invocationCallOrder[0],
);
});
it('filters OAuth prompts before saving partial responses on disconnect', async () => {
const conversationId = 'conversation-123';
let allSubscribersLeftHandler;
mockGenerationJobManager.createJob.mockResolvedValue({
createdAt: 1000,
readyPromise: Promise.resolve(),
abortController: new AbortController(),
emitter: {
on: jest.fn((event, handler) => {
if (event === 'allSubscribersLeft') {
allSubscribersLeftHandler = handler;
}
}),
},
});
mockGenerationJobManager.getResumeState.mockResolvedValue({
conversationId,
responseMessageId: 'response-message',
userMessage: {
messageId: 'user-message',
parentMessageId: 'parent-message',
conversationId,
text: 'Use Google Workspace',
},
});
const initializeClient = jest.fn().mockRejectedValue(new Error('stop after setup'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Use Google Workspace',
messageId: 'user-message',
parentMessageId: 'parent-message',
conversationId,
endpointOption: {
endpoint: 'agents',
modelOptions: { model: 'gpt-3.5-turbo' },
},
},
config: {},
};
const res = {
headersSent: true,
json: jest.fn(() => {
res.headersSent = true;
}),
status: jest.fn(() => res),
};
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(allSubscribersLeftHandler).toEqual(expect.any(Function));
const oauthPart = {
type: 'tool_call',
tool_call: {
name: 'oauth_mcp_Google-Workspace',
auth: 'https://auth.example.com/oauth',
},
};
const textPart = { type: 'text', text: 'Partial response...' };
await allSubscribersLeftHandler([oauthPart, textPart]);
expect(mockFilterPersistableAbortContent).toHaveBeenCalledWith([oauthPart, textPart]);
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-123' }),
expect.objectContaining({
content: [textPart],
messageId: 'response-message',
parentMessageId: 'user-message',
}),
expect.any(Object),
);
});
});

View file

@ -6,6 +6,7 @@ const {
buildMessageFiles,
resolveTitleTiming,
GenerationJobManager,
filterPersistableAbortContent,
decrementPendingRequest,
sanitizeMessageForTransmit,
checkAndIncrementPendingRequest,
@ -75,6 +76,31 @@ async function attachConversationCreatedAt(req, { userId, conversationId, isNewC
}
}
function getPreliminaryResponseMessageId({ messageId, responseMessageId }) {
if (typeof responseMessageId === 'string' && responseMessageId.length > 0) {
return responseMessageId;
}
if (typeof messageId !== 'string' || messageId.length === 0) {
return null;
}
return `${messageId.replace(/_+$/, '')}_`;
}
function getPreliminaryUserMessage({ messageId, parentMessageId, text }, conversationId) {
if (typeof messageId !== 'string' || messageId.length === 0) {
return null;
}
return {
messageId,
parentMessageId,
conversationId,
text,
};
}
/**
* Resumable Agent Controller - Generation runs independently of HTTP connection.
* Returns streamId immediately, client subscribes separately via SSE.
@ -134,6 +160,16 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
await attachConversationCreatedAt(req, { userId, conversationId, isNewConvo });
const preliminaryUserMessage = getPreliminaryUserMessage(req.body, conversationId);
const preliminaryResponseMessageId = getPreliminaryResponseMessageId(req.body);
if (preliminaryUserMessage || preliminaryResponseMessageId) {
await GenerationJobManager.updateMetadata(streamId, {
conversationId,
responseMessageId: preliminaryResponseMessageId,
userMessage: preliminaryUserMessage,
});
}
// Note: We no longer use res.on('close') to abort since we send JSON immediately.
// The response closes normally after res.json(), which is not an abort condition.
// Abort handling is done through GenerationJobManager via the SSE stream connection.
@ -155,6 +191,12 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
return;
}
const persistableContent = filterPersistableAbortContent(aggregatedContent);
if (persistableContent.length === 0) {
logger.debug('[ResumableAgentController] No persistable content to save partial response');
return;
}
const resumeState = await GenerationJobManager.getResumeState(streamId);
if (!resumeState?.userMessage) {
logger.debug('[ResumableAgentController] No user message to save partial response for');
@ -170,7 +212,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
conversationId: responseConversationId,
parentMessageId: resumeState.userMessage.messageId,
sender: client?.sender ?? 'AI',
content: aggregatedContent,
content: persistableContent,
unfinished: true,
error: false,
isCreatedByUser: false,
@ -194,7 +236,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
);
logger.debug(
`[ResumableAgentController] Saved partial response for ${streamId}, content parts: ${aggregatedContent.length}`,
`[ResumableAgentController] Saved partial response for ${streamId}, content parts: ${persistableContent.length}`,
);
} catch (error) {
logger.error('[ResumableAgentController] Error saving partial response:', error);

View file

@ -195,6 +195,43 @@ describe('Agent Abort Endpoint', () => {
expect(response.status).toBe(200);
expect(mockSaveMessage).not.toHaveBeenCalled();
});
it('should skip message saving when abort content is only an OAuth prompt', async () => {
const jobStreamId = 'test-stream-123';
mockGenerationJobManager.getJob.mockResolvedValue({
metadata: { userId: 'test-user-123' },
});
mockGenerationJobManager.abortJob.mockResolvedValue({
success: true,
jobData: {
userMessage: { messageId: 'user-msg-123' },
responseMessageId: 'response-msg-456',
conversationId: jobStreamId,
},
content: [
{
type: 'tool_call',
tool_call: {
type: 'tool_call',
id: 'oauth-call-1',
name: 'oauth_mcp_Google-Workspace',
args: '',
auth: 'https://auth.example.com/oauth',
},
},
],
text: '',
});
const response = await request(app)
.post('/api/agents/chat/abort')
.send({ conversationId: jobStreamId });
expect(response.status).toBe(200);
expect(mockSaveMessage).not.toHaveBeenCalled();
});
});
describe('Partial Response Saving', () => {
@ -264,8 +301,8 @@ describe('Agent Abort Endpoint', () => {
responseMessageId: 'response-msg-456',
conversationId: jobStreamId,
},
content: [],
text: '',
content: [{ type: 'text', text: 'Partial response...' }],
text: 'Partial response...',
});
mockSaveMessage.mockRejectedValue(new Error('Database error'));

View file

@ -45,9 +45,11 @@ jest.mock('~/server/middleware', () => ({
}));
jest.mock('~/server/routes/agents/chat', () => require('express').Router());
jest.mock('~/server/routes/agents/v1', () => ({
v1: require('express').Router(),
}));
jest.mock('~/server/routes/agents/v1', () => {
const router = require('express').Router();
router.use((req, res) => res.status(418).json({ error: 'v1 caught stream route' }));
return { v1: router };
});
jest.mock('~/server/routes/agents/openai', () => require('express').Router());
jest.mock('~/server/routes/agents/responses', () => require('express').Router());

View file

@ -1,5 +1,5 @@
const express = require('express');
const { isEnabled, GenerationJobManager } = require('@librechat/api');
const { isEnabled, GenerationJobManager, hasPersistableAbortContent } = require('@librechat/api');
const { createSseStreamTelemetry } = require('@librechat/api/telemetry');
const { logger } = require('@librechat/data-schemas');
const {
@ -43,8 +43,6 @@ router.use(requireJwtAuth);
router.use(checkBan);
router.use(uaParser);
router.use('/', v1);
/**
* Stream endpoints - mounted before chatRouter to bypass rate limiters
* These are GET requests and don't need message body validation or rate limiting
@ -273,7 +271,8 @@ router.post('/chat/abort', async (req, res) => {
if (
abortResult.success &&
abortResult.jobData?.userMessage?.messageId &&
abortResult.jobData?.responseMessageId
abortResult.jobData?.responseMessageId &&
hasPersistableAbortContent(abortResult.content)
) {
const { jobData, content, text } = abortResult;
const responseMessage = {
@ -314,6 +313,8 @@ router.post('/chat/abort', async (req, res) => {
return res.status(404).json({ error: 'Job not found', streamId: jobStreamId });
});
router.use('/', v1);
const chatRouter = express.Router();
chatRouter.use(configMiddleware);

View file

@ -1,11 +1,6 @@
const { tool } = require('@librechat/agents/langchain/tools');
const { logger, getTenantId } = require('@librechat/data-schemas');
const {
Providers,
StepTypes,
GraphEvents,
Constants: AgentConstants,
} = require('@librechat/agents');
const { Providers, Constants: AgentConstants } = require('@librechat/agents');
const {
sendEvent,
MCPOAuthHandler,
@ -14,7 +9,11 @@ const {
normalizeJsonSchema,
GenerationJobManager,
resolveJsonSchemaRefs,
buildOAuthToolCallName,
buildMCPAuthStepId,
buildMCPAuthToolCall,
buildMCPAuthRunStepEvent,
buildMCPAuthRunStepDeltaEvent,
buildMCPAuthRunStepEndDeltaEvent,
checkAccessWithRequestCache,
} = require('@librechat/api');
const {
@ -201,20 +200,11 @@ function isEmptyObjectSchema(jsonSchema) {
function createRunStepDeltaEmitter({ res, stepId, toolCall, streamId = null }) {
/**
* @param {string} authURL - The URL to redirect the user for OAuth authentication.
* @param {{ expiresAt?: number }} [options]
* @returns {Promise<void>}
*/
return async function (authURL) {
/** @type {{ id: string; delta: AgentToolCallDelta }} */
const data = {
id: stepId,
delta: {
type: StepTypes.TOOL_CALLS,
tool_calls: [{ ...toolCall, args: '' }],
auth: authURL,
expires_at: Date.now() + Time.TWO_MINUTES,
},
};
const eventData = { event: GraphEvents.ON_RUN_STEP_DELTA, data };
return async function (authURL, options) {
const eventData = buildMCPAuthRunStepDeltaEvent({ authURL, stepId, toolCall, options });
if (streamId) {
await GenerationJobManager.emitChunk(streamId, eventData);
} else {
@ -235,18 +225,7 @@ function createRunStepDeltaEmitter({ res, stepId, toolCall, streamId = null }) {
*/
function createRunStepEmitter({ res, runId, stepId, toolCall, index, streamId = null }) {
return async function () {
/** @type {import('@librechat/agents').RunStep} */
const data = {
runId: runId ?? Constants.USE_PRELIM_RESPONSE_MESSAGE_ID,
id: stepId,
type: StepTypes.TOOL_CALLS,
index: index ?? 0,
stepDetails: {
type: StepTypes.TOOL_CALLS,
tool_calls: [toolCall],
},
};
const eventData = { event: GraphEvents.ON_RUN_STEP, data };
const eventData = buildMCPAuthRunStepEvent({ runId, stepId, toolCall, index });
if (streamId) {
await GenerationJobManager.emitChunk(streamId, eventData);
} else {
@ -260,20 +239,43 @@ function createRunStepEmitter({ res, runId, stepId, toolCall, index, streamId =
* @param {object} params
* @param {string} params.flowId - The ID of the login flow.
* @param {FlowStateManager<any>} params.flowManager - The flow manager instance.
* @param {(authURL: string) => void} [params.callback]
* @param {(authURL: string, options?: { expiresAt?: number }) => void | Promise<void>} [params.callback]
*/
function createOAuthStart({ flowId, flowManager, callback }) {
/**
* Creates a function to handle OAuth login requests.
* @param {string} authURL - The URL to redirect the user for OAuth authentication.
* @param {{ expiresAt?: number }} [options]
* @returns {Promise<boolean>} Returns true to indicate the event was sent successfully.
*/
return async function (authURL) {
return async function (authURL, options) {
let emitted = false;
const emitOAuthStart = async (message) => {
if (options) {
await callback?.(authURL, options);
} else {
await callback?.(authURL);
}
emitted = true;
logger.debug(message);
};
const existingFlow = await flowManager.getFlowState(flowId, 'oauth_login');
if (existingFlow) {
await emitOAuthStart('Re-sent OAuth login request to client');
return true;
}
await flowManager.createFlowWithHandler(flowId, 'oauth_login', async () => {
callback?.(authURL);
logger.debug('Sent OAuth login request to client');
await emitOAuthStart('Sent OAuth login request to client');
return true;
});
if (!emitted) {
await emitOAuthStart('Re-sent OAuth login request to client');
}
return true;
};
}
@ -286,15 +288,7 @@ function createOAuthStart({ flowId, flowManager, callback }) {
*/
function createOAuthEnd({ res, stepId, toolCall, streamId = null }) {
return async function () {
/** @type {{ id: string; delta: AgentToolCallDelta }} */
const data = {
id: stepId,
delta: {
type: StepTypes.TOOL_CALLS,
tool_calls: [{ ...toolCall }],
},
};
const eventData = { event: GraphEvents.ON_RUN_STEP_DELTA, data };
const eventData = buildMCPAuthRunStepEndDeltaEvent({ stepId, toolCall });
if (streamId) {
await GenerationJobManager.emitChunk(streamId, eventData);
} else {
@ -323,14 +317,14 @@ function createAbortHandler({ userId, serverName, toolName, flowManager }) {
/**
* @param {Object} params
* @param {() => void} params.runStepEmitter
* @param {(authURL: string) => void} params.runStepDeltaEmitter
* @returns {(authURL: string) => void}
* @param {() => Promise<void>} params.runStepEmitter
* @param {(authURL: string, options?: { expiresAt?: number }) => Promise<void>} params.runStepDeltaEmitter
* @returns {(authURL: string, options?: { expiresAt?: number }) => Promise<void>}
*/
function createOAuthCallback({ runStepEmitter, runStepDeltaEmitter }) {
return function (authURL) {
runStepEmitter();
runStepDeltaEmitter(authURL);
return async function (authURL, options) {
await runStepEmitter();
await runStepDeltaEmitter(authURL, options);
};
}
@ -373,12 +367,11 @@ async function reconnectServer({
const runId = Constants.USE_PRELIM_RESPONSE_MESSAGE_ID;
const flowId = `${user.id}:${serverName}:${Date.now()}`;
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
const stepId = 'step_oauth_login_' + serverName;
const toolCall = {
const stepId = buildMCPAuthStepId(serverName);
const toolCall = buildMCPAuthToolCall({
id: flowId,
name: buildOAuthToolCallName(serverName),
type: 'tool_call_chunk',
};
serverName,
});
// Set up abort handler to clean up OAuth flows if request is aborted
const oauthFlowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
@ -949,6 +942,7 @@ module.exports = {
resolveConfigServers,
resolveMcpConfigNames,
resolveAllMcpConfigs,
createOAuthStart,
checkOAuthFlowStatus,
getServerConnectionStatus,
createUnavailableToolStub,

View file

@ -45,6 +45,7 @@ const {
createMCPTools,
createMCPPermissionContext,
getMCPSetupData,
createOAuthStart,
checkOAuthFlowStatus,
getServerConnectionStatus,
createUnavailableToolStub,
@ -100,6 +101,85 @@ describe('tests for the new helper functions used by the MCP connection status e
mockGetOAuthReconnectionManager = require('~/config').getOAuthReconnectionManager;
});
describe('createOAuthStart', () => {
const flowId = 'test-server:oauth_login:thread-1:run-1';
const authUrl = 'https://auth.example.com/oauth?state=test';
it('should create a login flow and emit the OAuth URL for the first request', async () => {
const callback = jest.fn();
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue(null),
createFlowWithHandler: jest.fn(async (_flowId, _type, handler) => handler()),
};
const oauthStart = createOAuthStart({
flowId,
flowManager: mockFlowManager,
callback,
});
await expect(oauthStart(authUrl)).resolves.toBe(true);
expect(mockFlowManager.getFlowState).toHaveBeenCalledWith(flowId, 'oauth_login');
expect(mockFlowManager.createFlowWithHandler).toHaveBeenCalledWith(
flowId,
'oauth_login',
expect.any(Function),
);
expect(callback).toHaveBeenCalledWith(authUrl);
expect(logger.debug).toHaveBeenCalledWith('Sent OAuth login request to client');
});
it('should replay the OAuth URL when the login flow already exists', async () => {
const callback = jest.fn();
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({
status: 'COMPLETED',
result: true,
}),
createFlowWithHandler: jest.fn(),
};
const oauthStart = createOAuthStart({
flowId,
flowManager: mockFlowManager,
callback,
});
await expect(oauthStart(authUrl)).resolves.toBe(true);
expect(mockFlowManager.getFlowState).toHaveBeenCalledWith(flowId, 'oauth_login');
expect(mockFlowManager.createFlowWithHandler).not.toHaveBeenCalled();
expect(callback).toHaveBeenCalledWith(authUrl);
expect(logger.debug).toHaveBeenCalledWith('Re-sent OAuth login request to client');
});
it('should replay the OAuth URL when flow creation is deduped internally', async () => {
const callback = jest.fn();
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue(null),
createFlowWithHandler: jest.fn().mockResolvedValue(true),
};
const oauthStart = createOAuthStart({
flowId,
flowManager: mockFlowManager,
callback,
});
await expect(oauthStart(authUrl)).resolves.toBe(true);
expect(mockFlowManager.getFlowState).toHaveBeenCalledWith(flowId, 'oauth_login');
expect(mockFlowManager.createFlowWithHandler).toHaveBeenCalledWith(
flowId,
'oauth_login',
expect.any(Function),
);
expect(callback).toHaveBeenCalledWith(authUrl);
expect(logger.debug).toHaveBeenCalledWith('Re-sent OAuth login request to client');
});
});
describe('getMCPSetupData', () => {
const mockUserId = 'user-123';
const mockConfig = {

View file

@ -2,8 +2,6 @@ const { logger } = require('@librechat/data-schemas');
const { tool: toolFn, DynamicStructuredTool } = require('@librechat/agents/langchain/tools');
const {
sleep,
StepTypes,
GraphEvents,
createToolSearch,
createBashExecutionTool,
Constants: AgentConstants,
@ -18,11 +16,17 @@ const {
isActionDomainAllowed,
buildWebSearchContext,
buildImageToolContext,
buildOAuthToolCallName,
buildToolClassification,
getMissingCustomUserVars,
buildWebSearchDynamicContext,
getCodeApiAuthHeaders,
getReplayablePendingMCPOAuthStart,
getMCPServerNamesFromTools,
buildMCPAuthToolCall,
buildMCPAuthStepId,
buildMCPAuthRunStepEvent,
buildMCPAuthRunStepDeltaEvent,
buildMCPAuthRunStepCompletedEvent,
isFileAuthoringToolDefinition,
} = require('@librechat/api');
const {
@ -596,44 +600,35 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
const flowManager = getFlowStateManager(flowsCache);
const configServers = await resolveConfigServers(req);
const pendingOAuthServers = new Set();
const pendingOAuthStarts = new Map();
const emittedOAuthStarts = new Map();
const oauthToolCallIds = new Map();
const oauthStepIndexes = new Map();
const createOAuthEmitter = (serverName, index) => {
return async (authURL) => {
const flowId = `${req.user.id}:${serverName}:${Date.now()}`;
const stepId = 'step_oauth_login_' + serverName;
return async (authURL, options) => {
if (emittedOAuthStarts.get(serverName) === authURL) {
return;
}
emittedOAuthStarts.set(serverName, authURL);
const flowId =
oauthToolCallIds.get(serverName) ?? `${req.user.id}:${serverName}:${Date.now()}`;
const stepId = buildMCPAuthStepId(serverName);
oauthToolCallIds.set(serverName, flowId);
oauthStepIndexes.set(serverName, index);
const toolCall = {
const toolCall = buildMCPAuthToolCall({
id: flowId,
name: buildOAuthToolCallName(serverName),
type: 'tool_call_chunk',
};
serverName,
});
const runStepData = {
runId: Constants.USE_PRELIM_RESPONSE_MESSAGE_ID,
id: stepId,
type: StepTypes.TOOL_CALLS,
index,
stepDetails: {
type: StepTypes.TOOL_CALLS,
tool_calls: [toolCall],
},
};
const runStepDeltaData = {
id: stepId,
delta: {
type: StepTypes.TOOL_CALLS,
tool_calls: [{ ...toolCall, args: '' }],
auth: authURL,
expires_at: Date.now() + Time.TWO_MINUTES,
},
};
const runStepEvent = { event: GraphEvents.ON_RUN_STEP, data: runStepData };
const runStepDeltaEvent = { event: GraphEvents.ON_RUN_STEP_DELTA, data: runStepDeltaData };
const runStepEvent = buildMCPAuthRunStepEvent({ stepId, toolCall, index });
const runStepDeltaEvent = buildMCPAuthRunStepDeltaEvent({
authURL,
stepId,
toolCall,
options,
});
if (streamId) {
await GenerationJobManager.emitChunk(streamId, runStepEvent);
@ -651,25 +646,19 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
const createOAuthEndEmitter = (serverName) => {
return async () => {
const stepId = 'step_oauth_login_' + serverName;
const toolCall = {
const stepId = buildMCPAuthStepId(serverName);
const toolCall = buildMCPAuthToolCall({
id: oauthToolCallIds.get(serverName),
name: buildOAuthToolCallName(serverName),
args: '',
output: 'OAuth authentication completed',
serverName,
type: 'tool_call',
};
const runStepCompletedEvent = {
event: GraphEvents.ON_RUN_STEP_COMPLETED,
data: {
result: {
id: stepId,
index: oauthStepIndexes.get(serverName) ?? 0,
tool_call: toolCall,
},
},
};
});
const runStepCompletedEvent = buildMCPAuthRunStepCompletedEvent({
stepId,
toolCall,
index: oauthStepIndexes.get(serverName) ?? 0,
});
if (streamId) {
await GenerationJobManager.emitChunk(streamId, runStepCompletedEvent);
@ -683,7 +672,45 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
};
};
const getPendingOAuthStartForEmit = async (serverName) => {
const cachedOAuthStart = pendingOAuthStarts.get(serverName);
if (cachedOAuthStart?.options?.expiresAt != null) {
return cachedOAuthStart;
}
const pendingOAuthStart = await getReplayablePendingMCPOAuthStart({
flowManager,
userId: req.user.id,
serverName,
});
if (!pendingOAuthStart) {
return cachedOAuthStart;
}
if (!cachedOAuthStart || pendingOAuthStart.authURL === cachedOAuthStart.authURL) {
pendingOAuthStarts.set(serverName, pendingOAuthStart);
return pendingOAuthStart;
}
return cachedOAuthStart;
};
const getOrFetchMCPServerTools = async (userId, serverName) => {
const addPendingOAuthServer = async () => {
const pendingOAuthStart = await getReplayablePendingMCPOAuthStart({
flowManager,
userId,
serverName,
});
if (!pendingOAuthStart) {
return false;
}
pendingOAuthServers.add(serverName);
pendingOAuthStarts.set(serverName, pendingOAuthStart);
return true;
};
let serverConfig;
try {
serverConfig =
@ -718,11 +745,19 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
const cached = await getMCPServerTools(userId, serverName);
if (cached) {
await addPendingOAuthServer();
return cached;
}
const oauthStart = async () => {
if (await addPendingOAuthServer()) {
return null;
}
const oauthStart = async (authURL, options) => {
pendingOAuthServers.add(serverName);
if (typeof authURL === 'string' && authURL.length > 0) {
pendingOAuthStarts.set(serverName, { authURL, options });
}
};
const result = await reinitMCPServer({
@ -813,6 +848,22 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
},
);
for (const serverName of getMCPServerNamesFromTools(filteredTools)) {
if (pendingOAuthServers.has(serverName)) {
continue;
}
const pendingOAuthStart = await getReplayablePendingMCPOAuthStart({
flowManager,
userId: req.user.id,
serverName,
});
if (pendingOAuthStart) {
pendingOAuthServers.add(serverName);
pendingOAuthStarts.set(serverName, pendingOAuthStart);
}
}
if (pendingOAuthServers.size > 0 && (res || streamId)) {
const serverNames = Array.from(pendingOAuthServers);
logger.info(
@ -821,6 +872,12 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
const oauthWaitPromises = serverNames.map(async (serverName, index) => {
try {
const pendingOAuthStart = await getPendingOAuthStartForEmit(serverName);
const oauthStart = createOAuthEmitter(serverName, index);
if (pendingOAuthStart) {
await oauthStart(pendingOAuthStart.authURL, pendingOAuthStart.options);
}
const result = await reinitMCPServer({
user: req.user,
serverName,
@ -828,7 +885,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
userMCPAuthMap,
flowManager,
returnOnOAuth: false,
oauthStart: createOAuthEmitter(serverName, index),
oauthStart,
oauthEnd: createOAuthEndEmitter(serverName),
connectionTimeout: Time.TWO_MINUTES,
});

View file

@ -20,7 +20,7 @@ const { getLogStores } = require('~/cache');
* @param {boolean} [params.forceNew]
* @param {number} [params.connectionTimeout]
* @param {FlowStateManager<any>} [params.flowManager]
* @param {(authURL: string) => Promise<void>} [params.oauthStart]
* @param {(authURL: string, options?: { expiresAt?: number }) => Promise<void>} [params.oauthStart]
* @param {() => Promise<void>} [params.oauthEnd]
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
*/

View file

@ -42,6 +42,7 @@ const mockLegacyDomainEncode = jest.fn();
const mockDecryptMetadata = jest.fn();
const mockCreateActionTool = jest.fn();
const mockGetServerConfig = jest.fn();
const mockFlowManager = { getFlowState: jest.fn() };
const mockResolveConfigServers = jest.fn();
const mockUserCanUseMCPServers = jest.fn().mockResolvedValue(true);
jest.mock('~/server/services/Tools/credentials', () => ({
@ -77,7 +78,7 @@ jest.mock('~/models', () => ({
findPluginAuthsByKeys: jest.fn(),
}));
jest.mock('~/config', () => ({
getFlowStateManager: jest.fn(() => ({})),
getFlowStateManager: jest.fn(() => mockFlowManager),
getMCPServersRegistry: jest.fn(() => ({
getServerConfig: (...args) => mockGetServerConfig(...args),
})),
@ -100,6 +101,7 @@ const {
resolveAgentCapabilities,
} = require('../ToolService');
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
const { PENDING_STALE_MS } = require('@librechat/api');
function createMockReq(capabilities) {
return {
@ -134,6 +136,7 @@ describe('ToolService - Action Capability Gating', () => {
mockGetCachedTools.mockResolvedValue(null);
mockGetUserMCPAuthMap.mockResolvedValue({});
mockGetServerConfig.mockResolvedValue(undefined);
mockFlowManager.getFlowState.mockResolvedValue(undefined);
mockResolveConfigServers.mockResolvedValue({});
});
@ -421,6 +424,349 @@ describe('ToolService - Action Capability Gating', () => {
expect(mockGetMCPServerTools).not.toHaveBeenCalled();
});
it('should re-emit pending MCP OAuth prompts when cached tool definitions exist', async () => {
const serverName = 'Google-Workspace';
const authorizationUrl = 'https://auth.example.com/Google-Workspace';
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
const capabilities = [AgentCapabilities.tools];
const req = createMockReq(capabilities);
const res = { writableEnded: false };
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
mockGetServerConfig.mockResolvedValue({
type: 'streamable-http',
url: 'https://demo.librechat.ai/mcp',
requiresOAuth: true,
});
mockGetMCPServerTools.mockResolvedValue({
[mcpTool]: {
function: {
name: mcpTool,
description: 'Cached search',
parameters: {},
},
},
});
mockFlowManager.getFlowState.mockResolvedValue({
status: 'PENDING',
createdAt: Date.now(),
metadata: { authorizationUrl },
});
mockLoadToolDefinitions.mockImplementation(async (params, deps) => {
const serverTools = await deps.getOrFetchMCPServerTools(params.userId, serverName);
return {
toolDefinitions: serverTools ? Object.keys(serverTools) : [],
toolRegistry: new Map(),
hasDeferredTools: false,
};
});
reinitMCPServer.mockImplementation(async ({ oauthStart }) => {
await oauthStart(authorizationUrl);
return { availableTools: { [mcpTool]: {} } };
});
const result = await loadAgentTools({
req,
res,
agent: { id: 'agent_123', tools: [mcpTool] },
definitionsOnly: true,
});
expect(result.toolDefinitions).toEqual([mcpTool]);
expect(mockGetMCPServerTools).toHaveBeenCalledWith(req.user.id, serverName);
expect(reinitMCPServer).toHaveBeenCalledWith(
expect.objectContaining({
serverName,
returnOnOAuth: false,
oauthStart: expect.any(Function),
}),
);
expect(mockSendEvent).toHaveBeenCalledWith(
res,
expect.objectContaining({
event: 'on_run_step',
data: expect.objectContaining({
id: `step_oauth_login_${serverName}`,
}),
}),
);
expect(mockSendEvent).toHaveBeenCalledWith(
res,
expect.objectContaining({
event: 'on_run_step_delta',
data: expect.objectContaining({
id: `step_oauth_login_${serverName}`,
delta: expect.objectContaining({
auth: authorizationUrl,
}),
}),
}),
);
});
it('should not join in-flight MCP initialization before replaying pending OAuth prompts', async () => {
const serverName = 'Google-Workspace';
const authorizationUrl = 'https://auth.example.com/Google-Workspace';
const mcpTool = `${Constants.mcp_all}${Constants.mcp_delimiter}${serverName}`;
const capabilities = [AgentCapabilities.tools];
const req = createMockReq(capabilities);
const res = { writableEnded: false };
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
mockGetServerConfig.mockResolvedValue({
type: 'streamable-http',
url: 'https://demo.librechat.ai/mcp',
requiresOAuth: true,
});
mockGetMCPServerTools.mockResolvedValue(null);
mockFlowManager.getFlowState.mockResolvedValue({
status: 'PENDING',
createdAt: Date.now(),
metadata: { authorizationUrl },
});
mockLoadToolDefinitions.mockImplementation(async (params, deps) => {
await deps.getOrFetchMCPServerTools(params.userId, serverName);
return {
toolDefinitions: [],
toolRegistry: new Map(),
hasDeferredTools: false,
};
});
reinitMCPServer.mockImplementation(async ({ oauthStart }) => {
await oauthStart(authorizationUrl);
return { availableTools: null };
});
await loadAgentTools({
req,
res,
agent: { id: 'agent_123', tools: [mcpTool] },
definitionsOnly: true,
});
expect(mockGetMCPServerTools).toHaveBeenCalledWith(req.user.id, serverName);
expect(reinitMCPServer).toHaveBeenCalledTimes(1);
expect(reinitMCPServer).toHaveBeenCalledWith(
expect.objectContaining({
serverName,
returnOnOAuth: false,
oauthStart: expect.any(Function),
}),
);
expect(mockSendEvent).toHaveBeenCalledWith(
res,
expect.objectContaining({
event: 'on_run_step_delta',
data: expect.objectContaining({
id: `step_oauth_login_${serverName}`,
delta: expect.objectContaining({
auth: authorizationUrl,
}),
}),
}),
);
});
it('should re-emit pending MCP OAuth prompts when selected MCP tools are already concrete', async () => {
const serverName = `Google${Constants.mcp_delimiter}Workspace`;
const authorizationUrl = 'https://auth.example.com/Google-Workspace';
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
const capabilities = [AgentCapabilities.tools];
const req = createMockReq(capabilities);
const res = { writableEnded: false };
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
mockFlowManager.getFlowState.mockResolvedValue({
status: 'PENDING',
createdAt: Date.now(),
metadata: { authorizationUrl },
});
mockLoadToolDefinitions.mockResolvedValue({
toolDefinitions: [mcpTool],
toolRegistry: new Map(),
hasDeferredTools: false,
});
reinitMCPServer.mockImplementation(async ({ oauthStart }) => {
await oauthStart(authorizationUrl);
return { availableTools: { [mcpTool]: {} } };
});
const result = await loadAgentTools({
req,
res,
agent: { id: 'agent_123', tools: [mcpTool] },
definitionsOnly: true,
});
expect(result.toolDefinitions).toEqual([mcpTool]);
expect(mockGetMCPServerTools).not.toHaveBeenCalled();
expect(reinitMCPServer).toHaveBeenCalledWith(
expect.objectContaining({
serverName,
returnOnOAuth: false,
oauthStart: expect.any(Function),
}),
);
expect(mockSendEvent).toHaveBeenCalledWith(
res,
expect.objectContaining({
event: 'on_run_step_delta',
data: expect.objectContaining({
id: `step_oauth_login_${serverName}`,
delta: expect.objectContaining({
auth: authorizationUrl,
}),
}),
}),
);
});
it('should emit stored pending MCP OAuth prompts before waiting on a silent in-flight join', async () => {
const serverName = 'Google-Workspace';
const authorizationUrl = 'https://auth.example.com/Google-Workspace';
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
const capabilities = [AgentCapabilities.tools];
const req = createMockReq(capabilities);
const res = { writableEnded: false };
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
mockFlowManager.getFlowState.mockResolvedValue({
status: 'PENDING',
createdAt: Date.now(),
metadata: { authorizationUrl },
});
mockLoadToolDefinitions.mockResolvedValue({
toolDefinitions: [mcpTool],
toolRegistry: new Map(),
hasDeferredTools: false,
});
reinitMCPServer.mockResolvedValue({ availableTools: null });
const result = await loadAgentTools({
req,
res,
agent: { id: 'agent_123', tools: [mcpTool] },
definitionsOnly: true,
});
expect(result.toolDefinitions).toEqual([mcpTool]);
expect(reinitMCPServer).toHaveBeenCalledWith(
expect.objectContaining({
serverName,
returnOnOAuth: false,
oauthStart: expect.any(Function),
}),
);
expect(mockSendEvent).toHaveBeenCalledWith(
res,
expect.objectContaining({
event: 'on_run_step_delta',
data: expect.objectContaining({
id: `step_oauth_login_${serverName}`,
delta: expect.objectContaining({
auth: authorizationUrl,
}),
}),
}),
);
});
it('should preserve OAuth URLs emitted while discovering MCP tools before a silent wait join', async () => {
const serverName = 'Google-Workspace';
const authorizationUrl = 'https://auth.example.com/Google-Workspace';
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
const capabilities = [AgentCapabilities.tools];
const req = createMockReq(capabilities);
const res = { writableEnded: false };
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
mockGetServerConfig.mockResolvedValue({
type: 'streamable-http',
url: 'https://demo.librechat.ai/mcp',
requiresOAuth: true,
});
mockGetMCPServerTools.mockResolvedValue(null);
mockFlowManager.getFlowState.mockResolvedValue(null);
mockLoadToolDefinitions.mockImplementation(async (params, deps) => {
await deps.getOrFetchMCPServerTools(params.userId, serverName);
return {
toolDefinitions: [],
toolRegistry: new Map(),
hasDeferredTools: false,
};
});
reinitMCPServer
.mockImplementationOnce(async ({ oauthStart }) => {
await oauthStart(authorizationUrl, { expiresAt: Date.now() + 60_000 });
return { availableTools: null };
})
.mockResolvedValue({ availableTools: null });
await loadAgentTools({
req,
res,
agent: { id: 'agent_123', tools: [mcpTool] },
definitionsOnly: true,
});
expect(reinitMCPServer).toHaveBeenCalledTimes(2);
expect(mockSendEvent).toHaveBeenCalledWith(
res,
expect.objectContaining({
event: 'on_run_step_delta',
data: expect.objectContaining({
id: `step_oauth_login_${serverName}`,
delta: expect.objectContaining({
auth: authorizationUrl,
}),
}),
}),
);
});
it('should preserve pending-flow expiry for OAuth URLs captured during discovery', async () => {
const serverName = 'Google-Workspace';
const authorizationUrl = 'https://auth.example.com/Google-Workspace';
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
const capabilities = [AgentCapabilities.tools];
const req = createMockReq(capabilities);
const res = { writableEnded: false };
const createdAt = Date.now() - 45_000;
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
mockGetServerConfig.mockResolvedValue({
type: 'streamable-http',
url: 'https://demo.librechat.ai/mcp',
requiresOAuth: true,
});
mockGetMCPServerTools.mockResolvedValue(null);
mockFlowManager.getFlowState.mockResolvedValueOnce(null).mockResolvedValueOnce({
status: 'PENDING',
createdAt,
metadata: { authorizationUrl },
});
mockLoadToolDefinitions.mockImplementation(async (params, deps) => {
await deps.getOrFetchMCPServerTools(params.userId, serverName);
return {
toolDefinitions: [],
toolRegistry: new Map(),
hasDeferredTools: false,
};
});
reinitMCPServer
.mockImplementationOnce(async ({ oauthStart }) => {
await oauthStart(authorizationUrl);
return { availableTools: null };
})
.mockResolvedValue({ availableTools: null });
await loadAgentTools({
req,
res,
agent: { id: 'agent_123', tools: [mcpTool] },
definitionsOnly: true,
});
const authDeltaEvent = mockSendEvent.mock.calls
.map(([, event]) => event)
.find((event) => event.data?.delta?.auth === authorizationUrl);
expect(authDeltaEvent?.data.delta.expires_at).toBe(createdAt + PENDING_STALE_MS);
});
it('should use request-scoped MCP config before falling back to the registry', async () => {
const serverName = 'config-server';
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;

View file

@ -351,6 +351,12 @@ export type TOptions = {
isResubmission?: boolean;
/** Currently only utilized when `isResubmission === true`, uses that message's currently attached files */
overrideFiles?: t.TMessage['files'];
/**
* Assistant message being regenerated. Used to derive the optimistic response
* id for non-tail regenerations without accidentally keying the stream to the
* conversation tail.
*/
targetResponseMessageId?: string | null;
/**
* Carry forward a user message's manually-invoked skills when the caller
* is resubmitting / regenerating that same message — the compose-time

View file

@ -73,8 +73,19 @@ export default function MCPServerStatusIcon({
const { connectionState } = serverStatus;
// Connecting: show spinner only (no action available)
// Connecting: show spinner, with cancel when an OAuth flow is pending.
if (connectionState === 'connecting') {
if (canCancel) {
return (
<LoadingStatusIcon
serverName={serverName}
onConfigClick={onConfigClick}
onCancel={onCancel}
canCancel={canCancel}
/>
);
}
return <ConnectingSpinner serverName={serverName} />;
}

View file

@ -38,15 +38,33 @@ export default function ServerInitializationSection({
const serverStatus = connectionStatus?.[serverName];
const isConnected = serverStatus?.connectionState === 'connected';
const canCancel = isCancellable(serverName);
const hasPendingOAuth =
requiresOAuth &&
serverStatus?.requiresOAuth === true &&
serverStatus.connectionState === 'connecting';
const canCancel = isCancellable(serverName) || hasPendingOAuth;
const isServerInitializing = isInitializing(serverName);
const serverOAuthUrl = getOAuthUrl(serverName);
const shouldShowReinit = isConnected && (requiresOAuth || hasCustomUserVars);
const shouldShowInit = !isConnected && !serverOAuthUrl;
const shouldShowInit = !isConnected && !serverOAuthUrl && !hasPendingOAuth;
if (!shouldShowReinit && !shouldShowInit && !serverOAuthUrl) {
return null;
if (!hasPendingOAuth) {
return null;
}
return (
<Button
onClick={() => cancelOAuthFlow(serverName)}
disabled={!canCancel}
variant="outline"
size={sidePanel ? 'sm' : 'default'}
className="w-full"
>
{localize('com_ui_cancel')}
</Button>
);
}
if (serverOAuthUrl) {

View file

@ -187,7 +187,9 @@ export function shouldShowActionButton(statusIconProps?: MCPServerStatusIconProp
// Show for disconnected/error (can reconnect/configure)
if (connectionState === 'disconnected' || connectionState === 'error') return true;
// Don't show connecting spinner (no action)
// Show a cancel action for pending OAuth connections.
if (connectionState === 'connecting' && requiresOAuth && canCancel) return true;
// Don't show connecting spinner when no action is available.
if (connectionState === 'connecting') return false;
// Connected: only show if there's config to manage
if (connectionState === 'connected') return hasCustomUserVars || requiresOAuth;

View file

@ -0,0 +1,143 @@
import { renderHook, act } from '@testing-library/react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { TConversation, TMessage, TSubmission } from 'librechat-data-provider';
import useChatFunctions from '../useChatFunctions';
const mockNavigate = jest.fn();
const mockSetShowStopButton = jest.fn();
const mockSetIsSubmitting = jest.fn();
const mockGetEphemeralAgent = jest.fn(() => null);
const mockSetFilesToDelete = jest.fn();
const mockGetSender = jest.fn(() => 'Assistant');
const mockGetExpiry = jest.fn(() => 'expiry-key');
const mockGetQueryData = jest.fn(() => ({}));
jest.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
}));
jest.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
getQueryData: mockGetQueryData,
}),
}));
jest.mock('recoil', () => ({
useRecoilValue: () => false,
useSetRecoilState: (atom: unknown) =>
String(atom).includes('isSubmitting') ? mockSetIsSubmitting : mockSetShowStopButton,
useRecoilCallback: (factory: any) =>
factory({
snapshot: {
getLoadable: () => ({ state: 'hasValue', contents: [] }),
},
reset: jest.fn(),
}),
}));
jest.mock('~/hooks/Files/useSetFilesToDelete', () => () => mockSetFilesToDelete);
jest.mock('~/hooks/Conversations/useGetSender', () => () => mockGetSender);
jest.mock('~/hooks/Input/useUserKey', () => () => ({ getExpiry: mockGetExpiry }));
jest.mock('~/hooks', () => ({
useAuthContext: () => ({ user: null }),
}));
jest.mock('~/store', () => ({
__esModule: true,
default: {
isTemporary: 'isTemporary',
isSubmittingFamily: () => 'isSubmitting',
showStopButtonByIndex: () => 'showStopButton',
pendingManualSkillsByConvoId: () => 'pendingManualSkills',
},
useGetEphemeralAgent: () => mockGetEphemeralAgent,
}));
jest.mock('~/utils', () => ({
logger: {
log: jest.fn(),
dir: jest.fn(),
},
createDualMessageContent: jest.fn(() => []),
getRouteChatProjectId: jest.fn(() => null),
}));
const userMessage = (messageId: string, parentMessageId = '00000000-0000-0000-0000-000000000000') =>
({
messageId,
parentMessageId,
conversationId: 'conversation-1',
isCreatedByUser: true,
sender: 'User',
text: messageId,
}) as TMessage;
const assistantMessage = (messageId: string, parentMessageId: string) =>
({
messageId,
parentMessageId,
conversationId: 'conversation-1',
isCreatedByUser: false,
sender: 'Assistant',
text: messageId,
}) as TMessage;
describe('useChatFunctions regenerate', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetQueryData.mockReturnValue({});
});
it('keys a non-tail regenerate to the selected assistant response', () => {
let messages = [
userMessage('user-1'),
assistantMessage('assistant-1', 'user-1'),
userMessage('user-2', 'assistant-1'),
assistantMessage('assistant-2', 'user-2'),
userMessage('user-3', 'assistant-2'),
assistantMessage('assistant-3', 'user-3'),
];
const setMessages = jest.fn((nextMessages: TMessage[]) => {
messages = nextMessages;
});
const setSubmission = jest.fn();
const conversation = {
conversationId: 'conversation-1',
endpoint: EModelEndpoint.agents,
model: 'gpt-4o',
agent_id: 'agent-1',
} as TConversation;
const { result } = renderHook(() =>
useChatFunctions({
isSubmitting: false,
latestMessage: messages[5],
conversation,
getMessages: () => messages,
setMessages,
setSubmission,
}),
);
act(() => {
result.current.regenerate(messages[1]);
});
const submission = setSubmission.mock.calls.at(-1)?.[0] as TSubmission;
expect(submission.userMessage.overrideParentMessageId).toBe('user-1');
expect(submission.userMessage.responseMessageId).toBe('assistant-1_');
expect(submission.initialResponse?.messageId).toBe('assistant-1_');
expect(submission.initialResponse?.parentMessageId).toBe('user-1');
expect(submission.messages.map((message) => message.messageId)).toEqual(['user-1']);
expect(submission.regenerateMessages?.map((message) => message.messageId)).toEqual([
'user-1',
'assistant-1',
'user-2',
'assistant-2',
'user-3',
'assistant-3',
]);
expect(
setMessages.mock.calls.at(-1)?.[0].map((message: TMessage) => message.messageId),
).toEqual(['user-1', 'assistant-1_']);
expect(messages.at(-1)?.messageId).toBe('assistant-1_');
});
});

View file

@ -0,0 +1,93 @@
import type { TMessage } from 'librechat-data-provider';
import {
getRegenerateSubmissionMessages,
getPreliminaryRegenerateResponseMessageId,
getRegenerateTargetResponseMessage,
} from '../useChatFunctions';
const userMessage = (messageId: string, parentMessageId = '00000000-0000-0000-0000-000000000000') =>
({
messageId,
parentMessageId,
isCreatedByUser: true,
sender: 'User',
text: messageId,
}) as TMessage;
const assistantMessage = (messageId: string, parentMessageId: string) =>
({
messageId,
parentMessageId,
isCreatedByUser: false,
sender: 'Assistant',
text: messageId,
}) as TMessage;
describe('regenerate response targeting', () => {
it('uses the clicked assistant response instead of the conversation tail', () => {
const messages = [
userMessage('user-1'),
assistantMessage('assistant-1', 'user-1'),
userMessage('user-2', 'assistant-1'),
assistantMessage('assistant-2', 'user-2'),
userMessage('user-3', 'assistant-2'),
assistantMessage('assistant-3', 'user-3'),
];
const targetResponse = getRegenerateTargetResponseMessage({
messages,
parentMessageId: 'user-1',
targetResponseMessageId: 'assistant-1',
latestMessage: messages[5],
});
expect(targetResponse?.messageId).toBe('assistant-1');
expect(getPreliminaryRegenerateResponseMessageId(targetResponse?.messageId)).toBe(
'assistant-1_',
);
});
it('only falls back to latestMessage when it belongs to the regenerated user turn', () => {
const messages = [
userMessage('user-1'),
assistantMessage('assistant-1', 'user-1'),
userMessage('user-2', 'assistant-1'),
assistantMessage('assistant-2', 'user-2'),
];
expect(
getRegenerateTargetResponseMessage({
messages,
parentMessageId: 'user-1',
latestMessage: messages[3],
})?.messageId,
).toBe('assistant-1');
expect(
getRegenerateTargetResponseMessage({
messages,
parentMessageId: 'user-2',
latestMessage: messages[3],
})?.messageId,
).toBe('assistant-2');
});
it('truncates regenerate history before the targeted assistant response', () => {
const messages = [
userMessage('user-1'),
assistantMessage('assistant-1', 'user-1'),
userMessage('user-2', 'assistant-1'),
assistantMessage('assistant-2', 'user-2'),
userMessage('user-3', 'assistant-2'),
assistantMessage('assistant-3', 'user-3'),
];
expect(
getRegenerateSubmissionMessages({
messages,
targetResponseMessage: messages[1],
initialResponseId: 'assistant-1_',
}).map((message) => message.messageId),
).toEqual(['user-1']);
});
});

View file

@ -70,6 +70,88 @@ const getAppendParentMessageId = ({
return failedUserMessage.parentMessageId ?? Constants.NO_PARENT;
};
type RegenerateTargetResponseArgs = {
messages: TMessage[];
parentMessageId?: string | null;
targetResponseMessageId?: string | null;
latestMessage?: TMessage | null;
};
const isAssistantResponseForParent = (
message: TMessage | null | undefined,
parentMessageId?: string | null,
): message is TMessage =>
!!message &&
!message.isCreatedByUser &&
!!parentMessageId &&
message.parentMessageId === parentMessageId;
export function getPreliminaryRegenerateResponseMessageId(
responseMessageId?: string | null,
): string | null {
if (typeof responseMessageId !== 'string' || responseMessageId.length === 0) {
return null;
}
return `${responseMessageId.replace(/_+$/, '')}_`;
}
export function getRegenerateTargetResponseMessage({
messages,
parentMessageId,
targetResponseMessageId,
latestMessage,
}: RegenerateTargetResponseArgs): TMessage | null {
if (!parentMessageId) {
return null;
}
if (targetResponseMessageId) {
const targetResponse = messages.find(
(message) =>
message.messageId === targetResponseMessageId &&
isAssistantResponseForParent(message, parentMessageId),
);
if (targetResponse) {
return targetResponse;
}
}
if (isAssistantResponseForParent(latestMessage, parentMessageId)) {
return latestMessage;
}
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (isAssistantResponseForParent(message, parentMessageId)) {
return message;
}
}
return null;
}
export function getRegenerateSubmissionMessages({
messages,
targetResponseMessage,
initialResponseId,
}: {
messages: TMessage[];
targetResponseMessage?: TMessage | null;
initialResponseId?: string | null;
}): TMessage[] {
if (targetResponseMessage?.messageId) {
const targetIndex = messages.findIndex(
(message) => message.messageId === targetResponseMessage.messageId,
);
if (targetIndex >= 0) {
return messages.slice(0, targetIndex);
}
}
return messages.filter((msg) => msg.messageId !== initialResponseId);
}
export default function useChatFunctions({
index = 0,
files,
@ -144,6 +226,7 @@ export default function useChatFunctions({
isEdited = false,
overrideMessages,
overrideFiles,
targetResponseMessageId,
overrideManualSkills,
addedConvo,
} = {},
@ -244,6 +327,14 @@ export default function useChatFunctions({
const targetParentMessage = currentMessages.find(
(msg) => msg.messageId === targetParentMessageId,
);
const targetResponseMessage = isRegenerate
? getRegenerateTargetResponseMessage({
messages: currentMessages,
parentMessageId: messageId,
targetResponseMessageId,
latestMessage,
})
: null;
let thread_id = targetParentMessage?.thread_id ?? latestMessage?.thread_id;
if (thread_id == null) {
@ -327,8 +418,10 @@ export default function useChatFunctions({
const responseMessageId =
editedMessageId ??
(latestMessage?.messageId && isRegenerate
? latestMessage.messageId.replace(/_+$/, '') + '_'
(isRegenerate
? getPreliminaryRegenerateResponseMessageId(
targetResponseMessage?.messageId ?? targetResponseMessageId,
)
: null) ??
null;
const initialResponseId =
@ -407,6 +500,15 @@ export default function useChatFunctions({
currentMessages = currentMessages.filter((msg) => msg.messageId !== responseMessageId);
}
const submissionMessages = isRegenerate
? getRegenerateSubmissionMessages({
messages: currentMessages,
targetResponseMessage,
initialResponseId: initialResponse.messageId,
})
: currentMessages;
const regenerateMessages = isRegenerate ? [...currentMessages] : undefined;
logger.log('message_state', initialResponse);
const submission: TSubmission = {
conversation: {
@ -420,7 +522,8 @@ export default function useChatFunctions({
responseMessageId,
overrideParentMessageId: isRegenerate ? messageId : null,
},
messages: currentMessages,
messages: submissionMessages,
regenerateMessages,
isEdited: isEditOrContinue,
isContinued,
isRegenerate,
@ -433,17 +536,24 @@ export default function useChatFunctions({
};
if (isRegenerate) {
setMessages([...submission.messages, initialResponse]);
setMessages([...submissionMessages, initialResponse]);
} else {
setMessages([...submission.messages, currentMsg, initialResponse]);
setMessages([...submissionMessages, currentMsg, initialResponse]);
}
setSubmission(submission);
logger.dir('message_stream', submission, { depth: null });
};
const regenerate = ({ parentMessageId }, options?: { addedConvo?: TConversation | null }) => {
const regenerate = (
message: Partial<Pick<TMessage, 'messageId' | 'parentMessageId' | 'isCreatedByUser'>>,
options?: { addedConvo?: TConversation | null },
) => {
const messages = getMessages();
const parentMessageId =
message.isCreatedByUser === true ? message.messageId : message.parentMessageId;
const targetResponseMessageId =
message.isCreatedByUser === true ? undefined : message.messageId;
const parentMessage = messages?.find((element) => element.messageId == parentMessageId);
if (parentMessage && parentMessage.isCreatedByUser) {
@ -452,6 +562,7 @@ export default function useChatFunctions({
{
isRegenerate: true,
addedConvo: options?.addedConvo ?? undefined,
targetResponseMessageId,
/** Carry the original user message's manual skill picks forward
* so the regenerated response is primed with the same skills.
* The compose-time atom was drained on the first submit; without

View file

@ -579,6 +579,9 @@ export function useMCPServerManager({
const hasCustomUserVars =
serverConfig?.customUserVars && Object.keys(serverConfig.customUserVars).length > 0;
const hasPendingOAuth =
serverStatus?.requiresOAuth === true && serverStatus.connectionState === 'connecting';
const canCancelOAuth = isCancellable(serverName) || hasPendingOAuth;
return {
serverName,
@ -592,8 +595,8 @@ export function useMCPServerManager({
} as TPlugin)
: undefined,
onConfigClick: handleConfigClick,
isInitializing: isInitializing(serverName),
canCancel: isCancellable(serverName),
isInitializing: isInitializing(serverName) || hasPendingOAuth,
canCancel: canCancelOAuth,
onCancel: handleCancelClick,
hasCustomUserVars,
};

View file

@ -1,11 +1,11 @@
import React from 'react';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, type MutableSnapshot } from 'recoil';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { act, renderHook } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { QueryKeys, type TConversation, type TMessage } from 'librechat-data-provider';
import { getBranchSiblingIndexesForTarget, getMessageBranchSiblingParentIds } from '~/utils';
import { useLatestMessage, useLatestMessageId } from '~/hooks/Messages/useLatestMessage';
import { getMessageBranchSiblingParentIds } from '~/utils';
import store from '~/store';
function createQueryClient() {
@ -254,3 +254,26 @@ describe('getMessageBranchSiblingParentIds', () => {
).toEqual([userMessage.messageId]);
});
});
describe('getBranchSiblingIndexesForTarget', () => {
it('returns sibling indexes that select the branch containing the target message', () => {
expect(
getBranchSiblingIndexesForTarget(
[
userMessage,
olderAssistantMessage,
olderFollowUpUserMessage,
olderFollowUpAssistantMessage,
assistantMessage,
],
olderFollowUpAssistantMessage.messageId,
conversation.conversationId,
),
).toEqual([
{
parentMessageId: userMessage.messageId,
siblingIdx: 1,
},
]);
});
});

View file

@ -0,0 +1,175 @@
import { Constants } from 'librechat-data-provider';
import type { EventSubmission, TMessage } from 'librechat-data-provider';
import {
buildCreatedInitialResponse,
getExistingConversationAbortMessages,
isInitialNewConversationSubmission,
mergeRegenerateFinalMessages,
} from '~/hooks/SSE/useEventHandlers';
describe('buildCreatedInitialResponse', () => {
const userMessage = {
messageId: 'server-user-message',
conversationId: 'conversation-1',
isCreatedByUser: true,
text: 'Hello',
sender: 'User',
} as TMessage;
const initialResponse = {
messageId: 'prelim-response',
parentMessageId: 'original-user-message',
conversationId: 'conversation-1',
isCreatedByUser: false,
text: '',
sender: 'Assistant',
} as TMessage;
it('uses the created user message id for new turns', () => {
expect(
buildCreatedInitialResponse({
initialResponse,
userMessage,
isRegenerate: false,
}),
).toEqual(
expect.objectContaining({
messageId: 'server-user-message_',
parentMessageId: 'server-user-message',
}),
);
});
it('preserves the regenerated prelim response id and parent', () => {
expect(
buildCreatedInitialResponse({
initialResponse,
userMessage,
isRegenerate: true,
}),
).toEqual(
expect.objectContaining({
messageId: 'prelim-response',
parentMessageId: 'original-user-message',
}),
);
});
});
describe('isInitialNewConversationSubmission', () => {
it('treats a root user message as an optimistic new chat', () => {
expect(
isInitialNewConversationSubmission({
userMessage: {
messageId: 'user-1',
parentMessageId: Constants.NO_PARENT,
} as TMessage,
} as EventSubmission),
).toBe(true);
});
it('does not treat follow-up turns as optimistic new chats', () => {
expect(
isInitialNewConversationSubmission({
userMessage: {
messageId: 'user-2',
parentMessageId: 'assistant-1',
} as TMessage,
} as EventSubmission),
).toBe(false);
});
});
describe('mergeRegenerateFinalMessages', () => {
const userMessage = (messageId: string, parentMessageId: string = Constants.NO_PARENT) =>
({
messageId,
parentMessageId,
conversationId: 'conversation-1',
isCreatedByUser: true,
sender: 'User',
text: messageId,
}) as TMessage;
const assistantMessage = (messageId: string, parentMessageId: string) =>
({
messageId,
parentMessageId,
conversationId: 'conversation-1',
isCreatedByUser: false,
sender: 'Assistant',
text: messageId,
}) as TMessage;
it('keeps the original branch siblings when a non-tail regenerate finalizes', () => {
const rootUser = userMessage('user-1');
const originalResponse = assistantMessage('assistant-1', rootUser.messageId);
const followUpUser = userMessage('user-2', originalResponse.messageId);
const followUpResponse = assistantMessage('assistant-2', followUpUser.messageId);
const finalResponse = assistantMessage('assistant-3', rootUser.messageId);
expect(
mergeRegenerateFinalMessages({
messages: [rootUser, originalResponse, followUpUser, followUpResponse],
responseMessage: finalResponse,
initialResponseId: 'assistant-1_',
}).map((message) => message.messageId),
).toEqual([
rootUser.messageId,
originalResponse.messageId,
followUpUser.messageId,
followUpResponse.messageId,
finalResponse.messageId,
]);
});
it('replaces the streamed preliminary response when it is present', () => {
const rootUser = userMessage('user-1');
const preliminaryResponse = assistantMessage('assistant-1_', rootUser.messageId);
const finalResponse = assistantMessage('assistant-3', rootUser.messageId);
expect(
mergeRegenerateFinalMessages({
messages: [rootUser, preliminaryResponse],
responseMessage: finalResponse,
initialResponseId: preliminaryResponse.messageId,
}).map((message) => message.messageId),
).toEqual([rootUser.messageId, finalResponse.messageId]);
});
});
describe('getExistingConversationAbortMessages', () => {
const message = (messageId: string) =>
({
messageId,
conversationId: 'conversation-1',
text: messageId,
}) as TMessage;
it('restores the full pre-regenerate branch on early abort', () => {
const originalMessages = [message('user-1'), message('assistant-1'), message('user-2')];
const scopedRegenerateMessages = [message('user-1')];
const currentStreamMessages = [message('user-1'), message('assistant-1_')];
expect(
getExistingConversationAbortMessages({
messages: scopedRegenerateMessages,
currentMessages: currentStreamMessages,
regenerateMessages: originalMessages,
isRegenerate: true,
}).map(({ messageId }) => messageId),
).toEqual(['user-1', 'assistant-1', 'user-2']);
});
it('keeps the existing non-regenerate abort rollback behavior', () => {
const submissionMessages = [message('user-1')];
const currentMessages = [message('user-1'), message('assistant-1')];
expect(
getExistingConversationAbortMessages({
messages: submissionMessages,
currentMessages,
}).map(({ messageId }) => messageId),
).toEqual(['user-1']);
});
});

View file

@ -1,6 +1,12 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { Constants, LocalStorageKeys, QueryKeys, request } from 'librechat-data-provider';
import type { TSubmission } from 'librechat-data-provider';
import {
Constants,
LocalStorageKeys,
QueryKeys,
StepEvents,
request,
} from 'librechat-data-provider';
import type { TMessage, TSubmission } from 'librechat-data-provider';
type SSEEventListener = (e: Partial<MessageEvent> & { responseCode?: number }) => void;
@ -107,6 +113,7 @@ jest.mock('~/data-provider', () => ({
}));
const mockErrorHandler = jest.fn();
const mockFinalHandler = jest.fn();
const mockCreatedHandler = jest.fn();
const mockStepHandler = jest.fn();
const mockTitleHandler = jest.fn();
@ -116,7 +123,7 @@ const mockClearStepMaps = jest.fn();
jest.mock('~/hooks/SSE/useEventHandlers', () =>
jest.fn(() => ({
errorHandler: mockErrorHandler,
finalHandler: jest.fn(),
finalHandler: mockFinalHandler,
createdHandler: mockCreatedHandler,
attachmentHandler: jest.fn(),
stepHandler: mockStepHandler,
@ -156,7 +163,7 @@ const CONV_ID = 'conv-abc-123';
type PartialSubmission = {
conversation: { conversationId?: string };
userMessage: Record<string, unknown>;
messages: never[];
messages: TMessage[];
isTemporary: boolean;
initialResponse: Record<string, unknown>;
endpointOption: { endpoint: string };
@ -190,7 +197,7 @@ const buildSubmission = (overrides: Partial<PartialSubmission> = {}): TSubmissio
const buildChatHelpers = () => ({
setMessages: jest.fn(),
getMessages: jest.fn(() => []),
getMessages: jest.fn<TMessage[], []>(() => []),
setConversation: jest.fn(),
setIsSubmitting: mockSetIsSubmitting,
newConversation: jest.fn(),
@ -229,6 +236,7 @@ describe('useResumableSSE - 404 error path', () => {
mockSSEInstances.length = 0;
localStorage.clear();
mockErrorHandler.mockClear();
mockFinalHandler.mockClear();
mockCreatedHandler.mockClear();
mockStepHandler.mockClear();
mockTitleHandler.mockClear();
@ -446,6 +454,46 @@ describe('useResumableSSE - 404 error path', () => {
unmount();
});
it('replaces the new-chat URL when the stream id is known despite a stale parent id', async () => {
window.history.pushState({}, '', '/c/new');
const submission = buildSubmission({
conversation: {},
userMessage: {
messageId: 'msg-1',
conversationId: null,
text: 'Hello',
isCreatedByUser: true,
sender: 'User',
parentMessageId: 'stale-parent-message',
},
initialResponse: {
messageId: 'msg-1_',
conversationId: null,
text: '',
isCreatedByUser: false,
sender: 'Assistant',
},
});
const chatHelpers = buildChatHelpers();
const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers));
await act(async () => {
await Promise.resolve();
});
expect(window.location.pathname).toBe('/c/stream-123');
expect(mockSetQueryData).toHaveBeenCalledWith(
[QueryKeys.messages, 'stream-123'],
expect.arrayContaining([
expect.objectContaining({ messageId: 'msg-1', conversationId: 'stream-123' }),
]),
);
unmount();
window.history.pushState({}, '', '/');
});
it('hydrates the submission conversation id before created handlers run', async () => {
const submission = buildSubmission({
conversation: {},
@ -496,6 +544,193 @@ describe('useResumableSSE - 404 error path', () => {
unmount();
});
it('queues run step events until created hydrates the submission', async () => {
const submission = buildSubmission({
conversation: {},
userMessage: {
messageId: 'msg-1',
conversationId: null,
text: 'Hello',
isCreatedByUser: true,
sender: 'User',
parentMessageId: Constants.NO_PARENT,
},
initialResponse: {
messageId: 'msg-1_',
conversationId: null,
text: '',
isCreatedByUser: false,
sender: 'Assistant',
},
});
const chatHelpers = buildChatHelpers();
const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers));
await act(async () => {
await Promise.resolve();
});
const runStepEvent = {
event: StepEvents.ON_RUN_STEP,
data: {
id: 'step-oauth',
runId: 'msg-1_',
index: 0,
stepDetails: {
type: 'tool_calls',
tool_calls: [{ id: 'call-oauth', name: 'Google-Workspace', args: '' }],
},
},
};
const sse = getLastSSE();
await act(async () => {
sse._emit('message', { data: JSON.stringify(runStepEvent) });
});
expect(mockStepHandler).not.toHaveBeenCalled();
await act(async () => {
sse._emit('message', {
data: JSON.stringify({
created: true,
message: {
messageId: 'msg-1',
conversationId: 'stream-123',
},
}),
});
});
expect(mockStepHandler).toHaveBeenCalledWith(
runStepEvent,
expect.objectContaining({
userMessage: expect.objectContaining({
messageId: 'msg-1',
conversationId: 'stream-123',
}),
}),
);
unmount();
});
it('renders OAuth run step events before created while retaining replay after hydration', async () => {
const submission = buildSubmission({
conversation: {},
userMessage: {
messageId: 'msg-1',
conversationId: null,
text: 'Hello',
isCreatedByUser: true,
sender: 'User',
parentMessageId: Constants.NO_PARENT,
},
initialResponse: {
messageId: 'msg-1_',
conversationId: null,
text: '',
isCreatedByUser: false,
sender: 'Assistant',
},
});
const chatHelpers = buildChatHelpers();
const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers));
await act(async () => {
await Promise.resolve();
});
const runStepEvent = {
event: StepEvents.ON_RUN_STEP,
data: {
id: 'step-oauth',
runId: Constants.USE_PRELIM_RESPONSE_MESSAGE_ID,
index: 0,
stepDetails: {
type: 'tool_calls',
tool_calls: [{ id: 'call-oauth', name: 'oauth_mcp_Google-Workspace', args: '' }],
},
},
};
const runStepDeltaEvent = {
event: StepEvents.ON_RUN_STEP_DELTA,
data: {
id: 'step-oauth',
delta: {
type: 'tool_calls',
tool_calls: [{ id: 'call-oauth', name: 'oauth_mcp_Google-Workspace', args: '' }],
auth: 'https://auth.example.com/oauth',
expires_at: 1780791946,
},
},
};
const sse = getLastSSE();
await act(async () => {
sse._emit('message', { data: JSON.stringify(runStepEvent) });
sse._emit('message', { data: JSON.stringify(runStepDeltaEvent) });
});
expect(mockStepHandler).toHaveBeenCalledTimes(2);
expect(mockStepHandler).toHaveBeenNthCalledWith(
1,
runStepEvent,
expect.objectContaining({
userMessage: expect.objectContaining({
messageId: 'msg-1',
conversationId: 'stream-123',
}),
}),
);
expect(mockStepHandler).toHaveBeenNthCalledWith(
2,
runStepDeltaEvent,
expect.objectContaining({
userMessage: expect.objectContaining({
messageId: 'msg-1',
conversationId: 'stream-123',
}),
}),
);
await act(async () => {
sse._emit('message', {
data: JSON.stringify({
created: true,
message: {
messageId: 'msg-1',
conversationId: 'stream-123',
},
}),
});
});
expect(mockStepHandler).toHaveBeenCalledTimes(4);
expect(mockStepHandler).toHaveBeenNthCalledWith(
3,
runStepEvent,
expect.objectContaining({
userMessage: expect.objectContaining({
messageId: 'msg-1',
conversationId: 'stream-123',
}),
}),
);
expect(mockStepHandler).toHaveBeenNthCalledWith(
4,
runStepDeltaEvent,
expect.objectContaining({
userMessage: expect.objectContaining({
messageId: 'msg-1',
conversationId: 'stream-123',
}),
}),
);
unmount();
});
it('routes title stream events to the title handler', async () => {
const submission = buildSubmission();
const chatHelpers = buildChatHelpers();
@ -637,6 +872,447 @@ describe('useResumableSSE - 404 error path', () => {
unmount();
});
it('replays OAuth run step delta events from resume state sync', async () => {
const submission = buildSubmission();
const chatHelpers = buildChatHelpers();
const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers));
await act(async () => {
await Promise.resolve();
});
const runStep = {
id: 'step-oauth',
runId: 'resp-1',
index: 0,
stepDetails: {
type: 'tool_calls',
tool_calls: [{ id: 'call-oauth', name: 'Google-Workspace', args: '' }],
},
};
const replayEvent = {
event: StepEvents.ON_RUN_STEP_DELTA,
data: {
id: 'step-oauth',
delta: {
type: 'tool_calls',
tool_calls: [{ name: 'Google-Workspace', args: '' }],
auth: 'https://auth.example.com/oauth',
expires_at: 1780791946,
},
},
};
const sse = getLastSSE();
await act(async () => {
sse._emit('message', {
data: JSON.stringify({
sync: true,
resumeState: {
runSteps: [runStep],
replayEvents: [replayEvent],
},
}),
});
});
expect(mockStepHandler).toHaveBeenNthCalledWith(
1,
{ event: StepEvents.ON_RUN_STEP, data: runStep },
expect.objectContaining({ userMessage: expect.objectContaining({ messageId: 'msg-1' }) }),
);
expect(mockStepHandler).toHaveBeenNthCalledWith(
2,
replayEvent,
expect.objectContaining({ userMessage: expect.objectContaining({ messageId: 'msg-1' }) }),
);
unmount();
});
it('anchors preliminary OAuth replay events to the response message from resume state sync', async () => {
const submission = buildSubmission({
userMessage: {
messageId: 'original-user',
conversationId: CONV_ID,
text: 'Original prompt',
isCreatedByUser: true,
sender: 'User',
parentMessageId: Constants.NO_PARENT,
},
initialResponse: {
messageId: 'original-response',
conversationId: CONV_ID,
text: 'Original response',
isCreatedByUser: false,
sender: 'Assistant',
parentMessageId: 'original-user',
},
});
const chatHelpers = buildChatHelpers();
const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers));
await act(async () => {
await Promise.resolve();
});
const runStep = {
id: 'step-oauth',
runId: Constants.USE_PRELIM_RESPONSE_MESSAGE_ID,
index: 0,
stepDetails: {
type: 'tool_calls',
tool_calls: [{ id: 'call-oauth', name: 'oauth_mcp_Google-Workspace', args: '' }],
},
};
const replayEvent = {
event: StepEvents.ON_RUN_STEP_DELTA,
data: {
id: 'step-oauth',
delta: {
type: 'tool_calls',
tool_calls: [{ id: 'call-oauth', name: 'oauth_mcp_Google-Workspace', args: '' }],
auth: 'https://auth.example.com/oauth',
expires_at: 1780791946,
},
},
};
const sse = getLastSSE();
await act(async () => {
sse._emit('message', {
data: JSON.stringify({
sync: true,
resumeState: {
runSteps: [runStep],
replayEvents: [replayEvent],
responseMessageId: 'follow-up-response',
conversationId: CONV_ID,
userMessage: {
messageId: 'follow-up-user',
parentMessageId: 'original-response',
conversationId: CONV_ID,
text: 'Follow-up prompt',
},
},
}),
});
});
expect(mockStepHandler).toHaveBeenNthCalledWith(
1,
{ event: StepEvents.ON_RUN_STEP, data: runStep },
expect.objectContaining({
userMessage: expect.objectContaining({ messageId: 'follow-up-user' }),
initialResponse: expect.objectContaining({
messageId: 'follow-up-response',
parentMessageId: 'follow-up-user',
}),
}),
);
expect(mockStepHandler).toHaveBeenNthCalledWith(
2,
replayEvent,
expect.objectContaining({
userMessage: expect.objectContaining({ messageId: 'follow-up-user' }),
initialResponse: expect.objectContaining({
messageId: 'follow-up-response',
parentMessageId: 'follow-up-user',
}),
}),
);
unmount();
});
it('merges resumed user and response messages into loaded conversation history', async () => {
const originalUser = {
messageId: 'original-user',
conversationId: CONV_ID,
text: 'Original prompt',
isCreatedByUser: true,
sender: 'User',
parentMessageId: '00000000-0000-0000-0000-000000000000',
};
const originalResponse = {
messageId: 'original-response',
conversationId: CONV_ID,
text: 'Original response',
isCreatedByUser: false,
sender: 'Assistant',
parentMessageId: 'original-user',
};
const submission = {
...buildSubmission({
conversation: { conversationId: CONV_ID },
userMessage: originalUser,
initialResponse: originalResponse,
}),
resumeStreamId: CONV_ID,
} as TSubmission & { resumeStreamId: string };
const chatHelpers = buildChatHelpers();
chatHelpers.getMessages.mockReturnValue([originalUser, originalResponse]);
const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers));
await act(async () => {
await Promise.resolve();
});
const sse = getLastSSE();
await act(async () => {
sse._emit('message', {
data: JSON.stringify({
sync: true,
resumeState: {
runSteps: [],
replayEvents: [],
aggregatedContent: [
{
type: 'tool_call',
tool_call: {
id: 'call-oauth',
name: 'oauth_mcp_Google-Workspace',
args: '',
},
},
],
responseMessageId: 'follow-up-response',
conversationId: CONV_ID,
userMessage: {
messageId: 'follow-up-user',
parentMessageId: 'original-response',
conversationId: CONV_ID,
text: 'Follow-up prompt',
},
},
}),
});
});
const lastMessages = chatHelpers.setMessages.mock.calls.at(-1)?.[0];
expect(lastMessages.map((message: { messageId: string }) => message.messageId)).toEqual([
'original-user',
'original-response',
'follow-up-user',
'follow-up-response',
]);
expect(lastMessages[2]).toEqual(
expect.objectContaining({
messageId: 'follow-up-user',
parentMessageId: 'original-response',
text: 'Follow-up prompt',
}),
);
expect(lastMessages[3]).toEqual(
expect.objectContaining({
messageId: 'follow-up-response',
parentMessageId: 'follow-up-user',
content: expect.any(Array),
}),
);
unmount();
});
it('uses the resumed submission for final events after sync', async () => {
const originalUser = {
messageId: 'original-user',
conversationId: CONV_ID,
text: 'Original prompt',
isCreatedByUser: true,
sender: 'User',
parentMessageId: String(Constants.NO_PARENT),
};
const originalResponse = {
messageId: 'original-response',
conversationId: CONV_ID,
text: 'Original response',
isCreatedByUser: false,
sender: 'Assistant',
parentMessageId: 'original-user',
};
const submission = {
...buildSubmission({
conversation: { conversationId: CONV_ID },
userMessage: originalUser,
initialResponse: originalResponse,
}),
resumeStreamId: CONV_ID,
} as TSubmission & { resumeStreamId: string };
const chatHelpers = buildChatHelpers();
chatHelpers.getMessages.mockReturnValue([originalUser, originalResponse]);
const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers));
await act(async () => {
await Promise.resolve();
});
const sse = getLastSSE();
await act(async () => {
sse._emit('message', {
data: JSON.stringify({
sync: true,
resumeState: {
runSteps: [],
replayEvents: [],
responseMessageId: 'follow-up-response',
conversationId: CONV_ID,
userMessage: {
messageId: 'follow-up-user',
parentMessageId: 'original-response',
conversationId: CONV_ID,
text: 'Follow-up prompt',
},
},
}),
});
});
const finalPayload = {
final: true,
conversation: { conversationId: CONV_ID },
requestMessage: {
messageId: 'follow-up-user',
parentMessageId: 'original-response',
conversationId: CONV_ID,
text: 'Follow-up prompt',
isCreatedByUser: true,
},
responseMessage: {
messageId: 'follow-up-response',
parentMessageId: 'follow-up-user',
conversationId: CONV_ID,
text: 'Done',
isCreatedByUser: false,
},
};
await act(async () => {
sse._emit('message', {
data: JSON.stringify(finalPayload),
});
});
expect(mockFinalHandler).toHaveBeenCalledWith(
finalPayload,
expect.objectContaining({
userMessage: expect.objectContaining({ messageId: 'follow-up-user' }),
initialResponse: expect.objectContaining({
messageId: 'follow-up-response',
parentMessageId: 'follow-up-user',
}),
}),
);
unmount();
});
it('uses the resumed submission for final events after reconnecting from sync', async () => {
jest.useFakeTimers();
const originalUser = {
messageId: 'original-user',
conversationId: CONV_ID,
text: 'Original prompt',
isCreatedByUser: true,
sender: 'User',
parentMessageId: String(Constants.NO_PARENT),
};
const originalResponse = {
messageId: 'original-response',
conversationId: CONV_ID,
text: 'Original response',
isCreatedByUser: false,
sender: 'Assistant',
parentMessageId: 'original-user',
};
const submission = {
...buildSubmission({
conversation: { conversationId: CONV_ID },
userMessage: originalUser,
initialResponse: originalResponse,
}),
resumeStreamId: CONV_ID,
} as TSubmission & { resumeStreamId: string };
const chatHelpers = buildChatHelpers();
chatHelpers.getMessages.mockReturnValue([originalUser, originalResponse]);
const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers));
await act(async () => {
await Promise.resolve();
});
const initialSSE = getLastSSE();
await act(async () => {
initialSSE._emit('message', {
data: JSON.stringify({
sync: true,
resumeState: {
runSteps: [],
replayEvents: [],
responseMessageId: 'follow-up-response',
conversationId: CONV_ID,
userMessage: {
messageId: 'follow-up-user',
parentMessageId: 'original-response',
conversationId: CONV_ID,
text: 'Follow-up prompt',
},
},
}),
});
});
await act(async () => {
initialSSE._emit('error');
});
await advanceRetryTimer(1000);
expect(mockSSEInstances).toHaveLength(2);
const reconnectedSSE = getLastSSE();
const finalPayload = {
final: true,
conversation: { conversationId: CONV_ID },
requestMessage: {
messageId: 'follow-up-user',
parentMessageId: 'original-response',
conversationId: CONV_ID,
text: 'Follow-up prompt',
isCreatedByUser: true,
},
responseMessage: {
messageId: 'follow-up-response',
parentMessageId: 'follow-up-user',
conversationId: CONV_ID,
text: 'Done',
isCreatedByUser: false,
},
};
await act(async () => {
reconnectedSSE._emit('message', {
data: JSON.stringify(finalPayload),
});
});
expect(mockFinalHandler).toHaveBeenLastCalledWith(
finalPayload,
expect.objectContaining({
userMessage: expect.objectContaining({ messageId: 'follow-up-user' }),
initialResponse: expect.objectContaining({
messageId: 'follow-up-response',
parentMessageId: 'follow-up-user',
}),
}),
);
unmount();
});
it.each([undefined, 500, 503])(
'does not call errorHandler for responseCode %s (reconnect path)',
async (responseCode) => {

View file

@ -67,6 +67,8 @@ function renderUseResumeOnLoad({
conversationId = CONVERSATION_ID,
messagesLoaded = true,
onSubmission,
siblingIndexParentId,
onSiblingIndex,
}: {
messages?: TMessage[];
getMessages?: () => TMessage[] | undefined;
@ -74,6 +76,8 @@ function renderUseResumeOnLoad({
conversationId?: string;
messagesLoaded?: boolean;
onSubmission?: (submission: TSubmission | null) => void;
siblingIndexParentId?: string;
onSiblingIndex?: (siblingIndex: number) => void;
}) {
const getMessages = jest.fn(getMessagesOverride ?? (() => messages));
const initializeState = (snapshot: MutableSnapshot) => {
@ -86,10 +90,18 @@ function renderUseResumeOnLoad({
onSubmission?.(currentSubmission);
return null;
};
const SiblingIndexProbe = () => {
const siblingIndex = useRecoilValue(store.messagesSiblingIdxFamily(siblingIndexParentId));
if (siblingIndexParentId) {
onSiblingIndex?.(siblingIndex);
}
return null;
};
const wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot initializeState={initializeState}>
<SubmissionProbe />
<SiblingIndexProbe />
{children}
</RecoilRoot>
);
@ -191,4 +203,121 @@ describe('useResumeOnLoad', () => {
expect(mockUseStreamStatus).toHaveBeenCalledWith(CONVERSATION_ID, true);
expect(observedSubmissions[observedSubmissions.length - 1]).toBe(submission);
});
it('restores the branch that owns a pending OAuth resume user message', async () => {
const rootUser = buildUserMessage(CONVERSATION_ID, 'root-user');
const branchOneResponse = {
messageId: 'branch-one-response',
parentMessageId: rootUser.messageId,
conversationId: CONVERSATION_ID,
text: 'Branch one response',
isCreatedByUser: false,
} as TMessage;
const branchOneFollowUp = buildUserMessage(CONVERSATION_ID, 'branch-one-follow-up');
branchOneFollowUp.parentMessageId = branchOneResponse.messageId;
const branchOneTail = {
messageId: 'branch-one-tail',
parentMessageId: branchOneFollowUp.messageId,
conversationId: CONVERSATION_ID,
text: 'Branch one tail',
isCreatedByUser: false,
} as TMessage;
const branchTwoResponse = {
messageId: 'branch-two-response',
parentMessageId: rootUser.messageId,
conversationId: CONVERSATION_ID,
text: 'Branch two response',
isCreatedByUser: false,
} as TMessage;
const observedSiblingIndexes: number[] = [];
mockUseStreamStatus.mockReturnValue({
isSuccess: true,
isFetching: false,
data: {
active: true,
status: 'running',
streamId: CONVERSATION_ID,
resumeState: {
runSteps: [],
aggregatedContent: [],
replayEvents: [],
responseMessageId: 'pending-user_',
conversationId: CONVERSATION_ID,
userMessage: {
messageId: 'pending-user',
parentMessageId: branchOneTail.messageId,
conversationId: CONVERSATION_ID,
text: 'Use OAuth tool on branch one',
},
},
},
});
renderUseResumeOnLoad({
messages: [rootUser, branchOneResponse, branchOneFollowUp, branchOneTail, branchTwoResponse],
siblingIndexParentId: rootUser.messageId,
onSiblingIndex: (siblingIndex) => observedSiblingIndexes.push(siblingIndex),
});
await act(async () => {
await Promise.resolve();
});
expect(observedSiblingIndexes[observedSiblingIndexes.length - 1]).toBe(1);
});
it('restores the assistant sibling selected by a pending regenerate response', async () => {
const rootUser = buildUserMessage(CONVERSATION_ID, 'root-user');
const olderResponse = {
messageId: 'older-response',
parentMessageId: rootUser.messageId,
conversationId: CONVERSATION_ID,
text: 'Older response',
isCreatedByUser: false,
} as TMessage;
const newerResponse = {
messageId: 'newer-response',
parentMessageId: rootUser.messageId,
conversationId: CONVERSATION_ID,
text: 'Newer response',
isCreatedByUser: false,
} as TMessage;
const observedSiblingIndexes: number[] = [];
mockUseStreamStatus.mockReturnValue({
isSuccess: true,
isFetching: false,
data: {
active: true,
status: 'running',
streamId: CONVERSATION_ID,
resumeState: {
runSteps: [],
aggregatedContent: [],
replayEvents: [],
responseMessageId: `${olderResponse.messageId}_`,
conversationId: CONVERSATION_ID,
userMessage: {
messageId: rootUser.messageId,
parentMessageId: rootUser.parentMessageId,
conversationId: CONVERSATION_ID,
text: rootUser.text,
},
},
},
});
renderUseResumeOnLoad({
messages: [rootUser, olderResponse, newerResponse],
siblingIndexParentId: rootUser.messageId,
onSiblingIndex: (siblingIndex) => observedSiblingIndexes.push(siblingIndex),
});
await act(async () => {
await Promise.resolve();
});
expect(observedSiblingIndexes[observedSiblingIndexes.length - 1]).toBe(1);
});
});

View file

@ -474,6 +474,369 @@ describe('useStepHandler', () => {
);
});
it('keeps the pending user message when replayed OAuth tool calls merge immediately', () => {
const rootUser = createUserMessage({ messageId: 'root-user' });
const selectedResponse = createResponseMessage({
messageId: 'selected-response',
parentMessageId: rootUser.messageId,
});
const followUpUser = createUserMessage({
messageId: 'follow-up-user',
parentMessageId: selectedResponse.messageId,
});
const followUpResponse = createResponseMessage({
messageId: 'follow-up-response',
parentMessageId: followUpUser.messageId,
});
const siblingResponse = createResponseMessage({
messageId: 'sibling-response',
parentMessageId: rootUser.messageId,
});
const pendingUser = createUserMessage({
messageId: 'pending-user',
parentMessageId: followUpResponse.messageId,
});
const initialResponse = createResponseMessage({
messageId: 'pending-user_',
parentMessageId: pendingUser.messageId,
});
let currentMessages = [
rootUser,
selectedResponse,
followUpUser,
followUpResponse,
siblingResponse,
];
mockGetMessages.mockImplementation(() => currentMessages);
mockSetMessages.mockImplementation((messages) => {
currentMessages = messages;
});
const { result } = renderHook(() => useStepHandler(createHookParams()));
const runStep = createToolCallRunStep({
id: 'step-oauth-login',
runId: USE_PRELIM_RESPONSE_MESSAGE_ID,
stepDetails: {
type: StepTypes.TOOL_CALLS,
tool_calls: [
{
id: 'tool-call-oauth',
name: `oauth${Constants.mcp_delimiter}Google-Workspace`,
args: '',
type: ToolCallTypes.TOOL_CALL,
},
],
},
});
const submission = createSubmission({
userMessage: pendingUser,
messages: currentMessages,
initialResponse,
});
act(() => {
result.current.stepHandler({ event: StepEvents.ON_RUN_STEP, data: runStep }, submission);
});
expect(currentMessages.map((message) => message.messageId)).toEqual([
rootUser.messageId,
selectedResponse.messageId,
followUpUser.messageId,
followUpResponse.messageId,
siblingResponse.messageId,
pendingUser.messageId,
initialResponse.messageId,
]);
expect(currentMessages.at(-1)).toEqual(
expect.objectContaining({
messageId: initialResponse.messageId,
parentMessageId: pendingUser.messageId,
content: expect.arrayContaining([
expect.objectContaining({
type: ContentTypes.TOOL_CALL,
tool_call: expect.objectContaining({
name: `oauth${Constants.mcp_delimiter}Google-Workspace`,
}),
}),
]),
}),
);
});
it('should not insert regenerate transport userMessage before preliminary OAuth steps', () => {
const originalUser = createUserMessage({ messageId: 'original-user-message' });
const originalResponse = createResponseMessage({
messageId: 'original-response-message',
parentMessageId: originalUser.messageId,
});
const initialResponse = createResponseMessage({
messageId: 'original-response-message_',
parentMessageId: originalUser.messageId,
});
const regenerateUserMessage = createUserMessage({
messageId: 'synthetic-regenerate-user-message',
parentMessageId: originalResponse.messageId,
});
let currentMessages = [originalUser];
mockGetMessages.mockImplementation(() => currentMessages);
mockSetMessages.mockImplementation((messages) => {
currentMessages = messages;
});
const { result } = renderHook(() => useStepHandler(createHookParams()));
const runStep = createToolCallRunStep({
id: 'step-oauth-login',
runId: USE_PRELIM_RESPONSE_MESSAGE_ID,
stepDetails: {
type: StepTypes.TOOL_CALLS,
tool_calls: [
{
id: 'tool-call-oauth',
name: `oauth${Constants.mcp_delimiter}Google-Workspace`,
args: '',
type: ToolCallTypes.TOOL_CALL,
},
],
},
});
const submission = createSubmission({
userMessage: regenerateUserMessage,
isRegenerate: true,
messages: [originalUser, originalResponse],
initialResponse,
});
act(() => {
result.current.stepHandler({ event: StepEvents.ON_RUN_STEP, data: runStep }, submission);
});
expect(currentMessages).toHaveLength(2);
expect(currentMessages.map((message) => message.messageId)).toEqual([
originalUser.messageId,
initialResponse.messageId,
]);
expect(currentMessages).not.toContainEqual(
expect.objectContaining({ messageId: regenerateUserMessage.messageId }),
);
expect(currentMessages[1]?.content).toContainEqual(
expect.objectContaining({
type: ContentTypes.TOOL_CALL,
tool_call: expect.objectContaining({
name: `oauth${Constants.mcp_delimiter}Google-Workspace`,
}),
}),
);
});
it('should preserve regenerate history when OAuth steps arrive before optimistic state commits', () => {
const originalUser = createUserMessage({ messageId: 'original-user-message' });
const originalResponse = createResponseMessage({
messageId: 'original-response-message',
parentMessageId: originalUser.messageId,
});
const initialResponse = createResponseMessage({
messageId: 'original-response-message_',
parentMessageId: originalUser.messageId,
});
const regenerateUserMessage = createUserMessage({
messageId: 'synthetic-regenerate-user-message',
parentMessageId: originalResponse.messageId,
});
mockGetMessages.mockReturnValue([]);
const { result } = renderHook(() => useStepHandler(createHookParams()));
const runStep = createToolCallRunStep({
id: 'step-oauth-login',
runId: USE_PRELIM_RESPONSE_MESSAGE_ID,
stepDetails: {
type: StepTypes.TOOL_CALLS,
tool_calls: [
{
id: 'tool-call-oauth',
name: `oauth${Constants.mcp_delimiter}Google-Workspace`,
args: '',
type: ToolCallTypes.TOOL_CALL,
},
],
},
});
const submission = createSubmission({
userMessage: regenerateUserMessage,
isRegenerate: true,
messages: [originalUser],
initialResponse,
});
act(() => {
result.current.stepHandler({ event: StepEvents.ON_RUN_STEP, data: runStep }, submission);
});
const lastCall = mockSetMessages.mock.calls[mockSetMessages.mock.calls.length - 1][0];
expect(lastCall).toHaveLength(2);
expect(lastCall.map((message: TMessage) => message.messageId)).toEqual([
originalUser.messageId,
initialResponse.messageId,
]);
expect(lastCall).not.toContainEqual(
expect.objectContaining({ messageId: regenerateUserMessage.messageId }),
);
expect(lastCall[1]?.content).toContainEqual(
expect.objectContaining({
type: ContentTypes.TOOL_CALL,
tool_call: expect.objectContaining({
name: `oauth${Constants.mcp_delimiter}Google-Workspace`,
}),
}),
);
});
it('does not seed non-tail regenerate run steps from the latest assistant message', () => {
const originalUser = createUserMessage({ messageId: 'original-user-message' });
const originalResponse = createResponseMessage({
messageId: 'original-response-message',
parentMessageId: originalUser.messageId,
content: [
{
type: ContentTypes.TEXT,
text: 'Original response',
},
],
});
const followUpUser = createUserMessage({
messageId: 'follow-up-user-message',
parentMessageId: originalResponse.messageId,
});
const latestResponse = createResponseMessage({
messageId: 'latest-response-message',
parentMessageId: followUpUser.messageId,
content: [
{
type: ContentTypes.TEXT,
text: 'Latest response content must not leak into regenerate',
},
],
});
const initialResponse = createResponseMessage({
messageId: 'original-response-message_',
parentMessageId: originalUser.messageId,
content: [],
});
const regenerateUserMessage = createUserMessage({
messageId: 'server-regenerate-user-message',
parentMessageId: originalResponse.messageId,
});
let currentMessages = [originalUser, originalResponse, followUpUser, latestResponse];
mockGetMessages.mockImplementation(() => currentMessages);
mockSetMessages.mockImplementation((messages) => {
currentMessages = messages;
});
const { result } = renderHook(() => useStepHandler(createHookParams()));
const runStep = createRunStep({
id: 'step-message-create',
runId: 'server-regenerated-response-message',
});
const submission = createSubmission({
userMessage: regenerateUserMessage,
isRegenerate: true,
messages: [originalUser],
initialResponse,
});
act(() => {
result.current.stepHandler({ event: StepEvents.ON_RUN_STEP, data: runStep }, submission);
});
const regeneratedResponse = currentMessages.find(
(message) => message.messageId === 'server-regenerated-response-message',
);
expect(regeneratedResponse).toEqual(
expect.objectContaining({
parentMessageId: originalUser.messageId,
content: [],
}),
);
expect(regeneratedResponse?.content).not.toEqual(latestResponse.content);
expect(currentMessages.map((message) => message.messageId)).toEqual([
originalUser.messageId,
'server-regenerated-response-message',
]);
});
it('preserves sibling assistant responses when regenerating one response', () => {
const originalUser = createUserMessage({ messageId: 'original-user-message' });
const selectedResponse = createResponseMessage({
messageId: 'selected-response-message',
parentMessageId: originalUser.messageId,
content: [
{
type: ContentTypes.TEXT,
text: 'Selected response',
},
],
});
const siblingResponse = createResponseMessage({
messageId: 'sibling-response-message',
parentMessageId: originalUser.messageId,
content: [
{
type: ContentTypes.TEXT,
text: 'Sibling response should stay visible',
},
],
});
const initialResponse = createResponseMessage({
messageId: 'selected-response-message_',
parentMessageId: originalUser.messageId,
content: [],
});
const regenerateUserMessage = createUserMessage({
messageId: 'server-regenerate-user-message',
parentMessageId: selectedResponse.messageId,
responseMessageId: initialResponse.messageId,
});
let currentMessages = [originalUser, selectedResponse, siblingResponse];
mockGetMessages.mockImplementation(() => currentMessages);
mockSetMessages.mockImplementation((messages) => {
currentMessages = messages;
});
const { result } = renderHook(() => useStepHandler(createHookParams()));
const runStep = createRunStep({
id: 'step-message-create',
runId: 'server-regenerated-response-message',
});
const submission = createSubmission({
userMessage: regenerateUserMessage,
isRegenerate: true,
messages: [originalUser, selectedResponse, siblingResponse],
initialResponse,
});
act(() => {
result.current.stepHandler({ event: StepEvents.ON_RUN_STEP, data: runStep }, submission);
});
expect(currentMessages.map((message) => message.messageId)).toEqual([
originalUser.messageId,
siblingResponse.messageId,
'server-regenerated-response-message',
]);
expect(currentMessages).not.toContainEqual(
expect.objectContaining({ messageId: selectedResponse.messageId }),
);
});
it('should propagate step metadata (agentId, groupId) for parallel rendering', () => {
const responseMessage = createResponseMessage();
mockGetMessages.mockReturnValue([responseMessage]);

View file

@ -68,6 +68,81 @@ type TTitleEvent = {
const hasRealTitle = (title?: string | null): title is string =>
title != null && title !== '' && title !== 'New Chat';
export const buildCreatedInitialResponse = ({
initialResponse,
userMessage,
isRegenerate = false,
}: Pick<EventSubmission, 'initialResponse' | 'userMessage' | 'isRegenerate'>): TMessage => ({
...initialResponse,
parentMessageId:
isRegenerate && initialResponse.parentMessageId
? initialResponse.parentMessageId
: userMessage.messageId,
messageId:
isRegenerate && initialResponse.messageId
? initialResponse.messageId
: `${userMessage.messageId}_`,
conversationId: userMessage.conversationId ?? initialResponse.conversationId,
});
export const isInitialNewConversationSubmission = ({
userMessage,
}: Pick<EventSubmission, 'userMessage'>): boolean =>
userMessage?.parentMessageId === Constants.NO_PARENT;
export const mergeRegenerateFinalMessages = ({
messages,
responseMessage,
initialResponseId,
}: {
messages: TMessage[];
responseMessage: TMessage;
initialResponseId?: string | null;
}): TMessage[] => {
const finalMessages: TMessage[] = [];
let inserted = false;
for (const message of messages) {
if (!message?.messageId || message.messageId === initialResponseId) {
continue;
}
if (message.messageId === responseMessage.messageId) {
finalMessages.push(responseMessage);
inserted = true;
continue;
}
finalMessages.push(message);
}
if (!inserted) {
finalMessages.push(responseMessage);
}
return finalMessages;
};
export const getExistingConversationAbortMessages = ({
messages,
currentMessages,
regenerateMessages,
isRegenerate = false,
}: Pick<EventSubmission, 'messages' | 'regenerateMessages' | 'isRegenerate'> & {
currentMessages?: TMessage[];
}): TMessage[] => {
if (!isRegenerate) {
return [...messages];
}
if (regenerateMessages?.length) {
return [...regenerateMessages];
}
const sourceMessages = currentMessages?.length ? currentMessages : messages;
return [...sourceMessages];
};
export type EventHandlerParams = {
isAddedRequest?: boolean;
setCompleted: React.Dispatch<React.SetStateAction<Set<unknown>>>;
@ -409,12 +484,11 @@ export default function useEventHandlers({
* drops it, which is the right behavior: by finalize the real
* `skill` tool_call is in `content` and takes over rendering.
*/
const initialResponse = {
...submission.initialResponse,
parentMessageId: userMessage.messageId,
messageId: userMessage.messageId + '_',
conversationId: userMessage.conversationId ?? submission.initialResponse.conversationId,
};
const initialResponse = buildCreatedInitialResponse({
initialResponse: submission.initialResponse,
userMessage,
isRegenerate,
});
if (isRegenerate) {
setMessages([...messages, initialResponse]);
} else {
@ -538,15 +612,40 @@ export default function useEventHandlers({
const serverConversation = conversation as TConversation;
try {
// Handle early abort - aborted during tool loading before any messages saved
// Don't update conversation state, just reset UI and stay on new chat
// Handle early abort - aborted before any response message was saved.
if ((data as Record<string, unknown>).earlyAbort) {
console.log(
'[finalHandler] Early abort detected - no messages saved, staying on new chat',
);
console.log('[finalHandler] Early abort detected - no response message saved');
setShowStopButton(false);
setIsSubmitting(false);
// Navigate to new chat if not already there
const currentConvoId = submissionConvo.conversationId;
const isInitialNewConvo = isInitialNewConversationSubmission(submission);
const isExistingConvo =
currentConvoId && currentConvoId !== Constants.NEW_CONVO && !isInitialNewConvo;
if (isExistingConvo) {
const abortMessages = getExistingConversationAbortMessages({
messages,
isRegenerate,
currentMessages: getMessages(),
regenerateMessages: submission.regenerateMessages,
});
setMessages(abortMessages);
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, currentConvoId],
abortMessages,
);
setDraft({ id: currentConvoId, value: requestMessage?.text });
return;
}
if (currentConvoId && currentConvoId !== Constants.NEW_CONVO) {
removeConvoFromAllQueries(queryClient, currentConvoId);
queryClient.removeQueries({ queryKey: [QueryKeys.conversation, currentConvoId] });
queryClient.removeQueries({ queryKey: [QueryKeys.messages, currentConvoId] });
}
setMessages([]);
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, Constants.NEW_CONVO], []);
setDraft({ id: String(Constants.NEW_CONVO), value: requestMessage?.text });
if (location.pathname !== `/c/${Constants.NEW_CONVO}`) {
navigate(`/c/${Constants.NEW_CONVO}`, { replace: true });
}
@ -622,7 +721,11 @@ export default function useEventHandlers({
if (runMessages) {
finalMessages = [...runMessages];
} else if (isRegenerate && responseMessage) {
finalMessages = [...messages, responseMessage];
finalMessages = mergeRegenerateFinalMessages({
messages: submission.regenerateMessages ?? currentMessages ?? messages,
responseMessage,
initialResponseId: submission.initialResponse.messageId,
});
} else if (requestMessage != null && responseMessage != null) {
finalMessages = [...messages, requestMessage, responseMessage];
}

View file

@ -15,6 +15,7 @@ import {
removeNullishValues,
} from 'librechat-data-provider';
import type {
Agents,
TMessage,
TPayload,
TSubmission,
@ -131,9 +132,73 @@ const hasConcreteConversationId = (conversationId?: string | null) =>
const isInitialNewConversation = (submission: TSubmission) => {
const conversationId = submission.conversation?.conversationId;
return (
submission.userMessage?.parentMessageId === Constants.NO_PARENT &&
!hasConcreteConversationId(conversationId)
return !hasConcreteConversationId(conversationId);
};
const getToolCallName = (toolCall: unknown) =>
toolCall != null && typeof toolCall === 'object' && 'name' in toolCall
? (toolCall.name as unknown)
: undefined;
const isOAuthToolCallName = (name: unknown) =>
typeof name === 'string' && name.startsWith(`oauth${Constants.mcp_delimiter}`);
const hasOAuthToolCall = (toolCalls: unknown) =>
Array.isArray(toolCalls) &&
toolCalls.some((toolCall) => isOAuthToolCallName(getToolCallName(toolCall)));
const isOAuthStepEvent = (data: unknown) => {
if (data == null || typeof data !== 'object' || !('event' in data)) {
return false;
}
const event = data.event;
const payload = 'data' in data ? data.data : undefined;
if (payload == null || typeof payload !== 'object') {
return false;
}
if (event === StepEvents.ON_RUN_STEP) {
const stepDetails = 'stepDetails' in payload ? payload.stepDetails : undefined;
return (
stepDetails != null &&
typeof stepDetails === 'object' &&
'tool_calls' in stepDetails &&
hasOAuthToolCall(stepDetails.tool_calls)
);
}
if (event === StepEvents.ON_RUN_STEP_DELTA) {
const delta = 'delta' in payload ? payload.delta : undefined;
if (delta == null || typeof delta !== 'object') {
return false;
}
return (
('auth' in delta && delta.auth != null) ||
('tool_calls' in delta && hasOAuthToolCall(delta.tool_calls))
);
}
if (event === StepEvents.ON_RUN_STEP_COMPLETED) {
const result = 'result' in payload ? payload.result : undefined;
if (result == null || typeof result !== 'object' || !('tool_call' in result)) {
return false;
}
return isOAuthToolCallName(getToolCallName(result.tool_call));
}
return false;
};
const replaceNewConversationUrl = (conversationId: string) => {
if (window.location.pathname !== `/c/${Constants.NEW_CONVO}`) {
return;
}
window.history.replaceState(
window.history.state,
'',
`/c/${conversationId}${window.location.search}`,
);
};
@ -194,6 +259,95 @@ const hydrateSubmissionMessages = (
: submission.initialResponse,
});
const buildResumeEventSubmission = (
currentSubmission: TSubmission,
currentUserMessage: TMessage,
resumeState?: Agents.ResumeState | null,
): EventSubmission => {
if (!resumeState) {
return { ...currentSubmission, userMessage: currentUserMessage } as EventSubmission;
}
const conversationId =
resumeState.conversationId ??
resumeState.userMessage?.conversationId ??
currentUserMessage.conversationId ??
currentSubmission.conversation?.conversationId;
const userMessage = {
...currentUserMessage,
...resumeState.userMessage,
conversationId,
isCreatedByUser: true,
} as TMessage;
const responseMessageId =
resumeState.responseMessageId ??
currentSubmission.initialResponse?.messageId ??
`${userMessage.messageId}_`;
const initialResponse = {
...(currentSubmission.initialResponse as TMessage),
messageId: responseMessageId,
parentMessageId: userMessage.messageId,
conversationId,
content:
resumeState.aggregatedContent ??
(currentSubmission.initialResponse as TMessage | undefined)?.content,
sender: resumeState.sender ?? currentSubmission.initialResponse?.sender,
isCreatedByUser: false,
} as TMessage;
return {
...currentSubmission,
conversation: {
...currentSubmission.conversation,
conversationId,
},
userMessage,
initialResponse,
} as EventSubmission;
};
const mergeResumeMessages = (
messages: TMessage[],
userMessage: TMessage,
responseMessage: TMessage,
): TMessage[] => {
const nextMessages = [...messages];
const userIndex = nextMessages.findIndex(
(message) => message.messageId === userMessage.messageId,
);
const responseIndex = nextMessages.findIndex(
(message) => message.messageId === responseMessage.messageId,
);
if (userIndex >= 0) {
nextMessages[userIndex] = { ...nextMessages[userIndex], ...userMessage };
}
if (responseIndex >= 0) {
nextMessages[responseIndex] = { ...nextMessages[responseIndex], ...responseMessage };
}
if (userIndex >= 0 && responseIndex >= 0) {
return nextMessages;
}
if (userIndex >= 0) {
const insertAt = userIndex + 1;
nextMessages.splice(insertAt, 0, responseMessage);
return nextMessages;
}
if (responseIndex >= 0) {
nextMessages.splice(responseIndex, 0, userMessage);
return nextMessages;
}
return [...nextMessages, userMessage, responseMessage];
};
/**
* Hook for resumable SSE streams.
* Separates generation start (POST) from stream subscription (GET EventSource).
@ -324,6 +478,17 @@ export default function useResumableSSE(
(currentStreamId: string, currentSubmission: TSubmission, isResume = false) => {
let { userMessage } = currentSubmission;
let textIndex: number | null = null;
const preCreatedStepEvents: Array<Parameters<typeof stepHandler>[0]> = [];
const replayPreCreatedStepEvents = () => {
if (preCreatedStepEvents.length === 0) {
return;
}
const submission = { ...currentSubmission, userMessage } as EventSubmission;
for (const event of preCreatedStepEvents.splice(0)) {
stepHandler(event, submission);
}
};
const baseUrl = `${apiBaseUrl()}/api/agents/chat/stream/${encodeURIComponent(currentStreamId)}`;
const url = isResume ? `${baseUrl}?resume=true` : baseUrl;
@ -391,6 +556,7 @@ export default function useResumableSSE(
overrideParentMessageId: userMessage.overrideParentMessageId,
};
createdHandler(data, { ...currentSubmission, userMessage } as EventSubmission);
replayPreCreatedStepEvents();
return;
}
@ -408,6 +574,15 @@ export default function useResumableSSE(
}
if (data.event != null) {
if (!isResume && !createdStreamIdsRef.current.has(currentStreamId)) {
if (isOAuthStepEvent(data)) {
preCreatedStepEvents.push(data);
stepHandler(data, { ...currentSubmission, userMessage } as EventSubmission);
return;
}
preCreatedStepEvents.push(data);
return;
}
stepHandler(data, { ...currentSubmission, userMessage } as EventSubmission);
return;
}
@ -420,13 +595,18 @@ export default function useResumableSSE(
const runId = v4();
setActiveRunId(runId);
const resumeSubmission = buildResumeEventSubmission(
currentSubmission,
userMessage,
data.resumeState,
);
currentSubmission = resumeSubmission;
submissionRef.current = resumeSubmission;
userMessage = resumeSubmission.userMessage;
if (data.resumeState?.runSteps) {
for (const runStep of data.resumeState.runSteps) {
stepHandler({ event: StepEvents.ON_RUN_STEP, data: runStep }, {
...currentSubmission,
userMessage,
} as EventSubmission);
stepHandler({ event: StepEvents.ON_RUN_STEP, data: runStep }, resumeSubmission);
}
}
@ -457,20 +637,20 @@ export default function useResumableSSE(
});
if (responseIdx >= 0) {
const updated = [...messages];
const oldContent = updated[responseIdx]?.content;
updated[responseIdx] = {
...updated[responseIdx],
const oldContent = messages[responseIdx]?.content;
const responseMessage = {
...messages[responseIdx],
content: data.resumeState.aggregatedContent,
};
} as TMessage;
const updated = mergeResumeMessages(messages, userMessage, responseMessage);
console.log('[ResumableSSE] SYNC updating message', {
messageId: updated[responseIdx]?.messageId,
messageId: responseMessage.messageId,
oldContentLength: Array.isArray(oldContent) ? oldContent.length : 0,
newContentLength: data.resumeState.aggregatedContent?.length,
});
setMessages(updated);
resetContentHandler();
syncStepMessage(updated[responseIdx]);
syncStepMessage(responseMessage);
console.log('[ResumableSSE] SYNC complete, handlers synced');
} else {
const responseId = serverResponseId ?? `${userMsgId}_`;
@ -482,7 +662,7 @@ export default function useResumableSSE(
content: data.resumeState.aggregatedContent,
isCreatedByUser: false,
} as TMessage;
setMessages([...messages, newMessage]);
setMessages(mergeResumeMessages(messages, userMessage, newMessage));
resetContentHandler();
syncStepMessage(newMessage);
}
@ -492,16 +672,26 @@ export default function useResumableSSE(
titleHandler(data.resumeState.titleEvent);
}
if (data.resumeState?.replayEvents?.length > 0) {
console.log(
`[ResumableSSE] Replaying ${data.resumeState.replayEvents.length} resume events`,
);
for (const replayEvent of data.resumeState.replayEvents) {
if (replayEvent.event != null) {
stepHandler(replayEvent, resumeSubmission);
}
}
}
if (data.pendingEvents?.length > 0) {
console.log(`[ResumableSSE] Replaying ${data.pendingEvents.length} pending events`);
const submission = { ...currentSubmission, userMessage } as EventSubmission;
for (const pendingEvent of data.pendingEvents) {
if (pendingEvent.event === 'title') {
titleHandler(pendingEvent);
} else if (pendingEvent.event != null) {
stepHandler(pendingEvent, submission);
stepHandler(pendingEvent, resumeSubmission);
} else if (pendingEvent.type != null) {
contentHandler({ data: pendingEvent, submission });
contentHandler({ data: pendingEvent, submission: resumeSubmission });
}
}
}
@ -939,12 +1129,13 @@ export default function useResumableSSE(
// Queue title generation if this is a new conversation (first message).
// Skip temporary conversations — the server never generates titles for
// them, so polling would 404 indefinitely.
const isNewConvo = submission.userMessage?.parentMessageId === Constants.NO_PARENT;
const isNewConvo = isInitialNewConversation(submission);
if (isNewConvo && !submission.isTemporary) {
queueTitleGeneration(newStreamId);
}
if (isInitialNewConversation(submission)) {
optimisticStreamIdsRef.current.add(newStreamId);
replaceNewConversationUrl(newStreamId);
}
const streamSubmission = addOptimisticConversation(newStreamId, submission);
submissionRef.current = streamSubmission;

View file

@ -1,8 +1,9 @@
import { useEffect, useRef } from 'react';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { useSetRecoilState, useRecoilValue, useRecoilCallback } from 'recoil';
import { Constants, tMessageSchema, isAssistantsEndpoint } from 'librechat-data-provider';
import type { TMessage, TConversation, TSubmission, Agents } from 'librechat-data-provider';
import type { StreamStatusResponse } from '~/data-provider';
import { getBranchSiblingIndexesForTarget } from '~/utils';
import { useStreamStatus } from '~/data-provider';
import store from '~/store';
@ -42,6 +43,41 @@ function resumeStateMatchesSubmission(
return !!responseMessageId && resumeState.responseMessageId === responseMessageId;
}
function getResumeBranchTargetMessageId(
resumeState: Agents.ResumeState,
messages: TMessage[],
): string | null | undefined {
const responseMessageId = resumeState.responseMessageId;
if (!responseMessageId) {
return resumeState.userMessage?.parentMessageId;
}
const unpaddedResponseMessageId = responseMessageId.replace(/_+$/, '');
let hasResponseMessage = false;
let hasUnpaddedResponseMessage = false;
for (const message of messages) {
if (message.messageId === responseMessageId) {
hasResponseMessage = true;
break;
}
if (message.messageId === unpaddedResponseMessageId) {
hasUnpaddedResponseMessage = true;
}
}
if (hasResponseMessage) {
return responseMessageId;
}
if (hasUnpaddedResponseMessage) {
return unpaddedResponseMessageId;
}
return resumeState.userMessage?.parentMessageId;
}
/**
* Build a submission object from resume state for reconnected streams.
* This provides the minimum data needed for useResumableSSE to subscribe.
@ -148,6 +184,22 @@ export default function useResumeOnLoad(
const resumableEnabled = !isAssistantsEndpoint(actualEndpoint);
// Track conversations we've already processed (either resumed or skipped)
const processedConvoRef = useRef<string | null>(null);
const restoreResumeBranch = useRecoilCallback(
({ set }) =>
(resumeState: Agents.ResumeState, messages: TMessage[], activeConversationId: string) => {
const targetMessageId = getResumeBranchTargetMessageId(resumeState, messages);
const branchIndexes = getBranchSiblingIndexesForTarget(
messages,
targetMessageId,
activeConversationId,
);
for (const { parentMessageId, siblingIdx } of branchIndexes) {
set(store.messagesSiblingIdxFamily(parentMessageId), siblingIdx);
}
},
[],
);
// Check for active stream when conversation changes
const submissionConvoId = currentSubmission?.conversation?.conversationId;
@ -266,6 +318,7 @@ export default function useResumeOnLoad(
// Build submission from resume state if available
if (streamStatus.resumeState) {
restoreResumeBranch(streamStatus.resumeState, messages, conversationId);
const submission = buildSubmissionFromResumeState(
streamStatus.resumeState,
streamStatus.streamId,
@ -308,6 +361,7 @@ export default function useResumeOnLoad(
streamStatus,
getMessages,
setSubmission,
restoreResumeBranch,
]);
// Reset processedConvoRef when conversation changes to allow re-checking

View file

@ -444,9 +444,91 @@ export default function useStepHandler({
const stepHandler = useCallback(
(stepEvent: TStepEvent, submission: EventSubmission) => {
const messages = getMessages() || [];
const submissionMessages = submission.messages ?? [];
const getEventMessages = (candidateMessages: TMessage[]) =>
submission.isRegenerate ? candidateMessages : getCurrentMessages(candidateMessages);
const messages = getEventMessages(submissionMessages);
const { userMessage } = submission;
let parentMessageId = userMessage.messageId;
const getRegenerateResponseIds = (responseMessageId: string) => {
const ids = new Set<string>();
const addId = (id?: string | null) => {
if (!id) {
return;
}
ids.add(id);
ids.add(id.replace(/_+$/, ''));
};
addId(responseMessageId);
addId(submission.initialResponse?.messageId);
addId(submission.userMessage?.responseMessageId);
return ids;
};
const shouldRemoveRegenerateResponse = (message: TMessage, responseMessageId: string) =>
submission.isRegenerate &&
!message.isCreatedByUser &&
getRegenerateResponseIds(responseMessageId).has(message.messageId);
const ensureUserMessagePresent = (
candidateMessages: TMessage[],
responseMessageId: string,
) => {
if (
submission.isRegenerate ||
!userMessage?.messageId ||
candidateMessages.some((message) => message.messageId === userMessage.messageId)
) {
return candidateMessages;
}
const responseIndex = candidateMessages.findIndex(
(message) => message.messageId === responseMessageId,
);
if (responseIndex < 0) {
return [...candidateMessages, userMessage as TMessage];
}
const nextMessages = [...candidateMessages];
nextMessages.splice(responseIndex, 0, userMessage as TMessage);
return nextMessages;
};
const getResponseBaseMessages = (
candidateMessages: TMessage[],
responseMessageId: string,
ensureUserMessage = false,
) => {
const currentMessages = getEventMessages(candidateMessages);
if (!submission.isRegenerate) {
return ensureUserMessage
? ensureUserMessagePresent(currentMessages, responseMessageId)
: currentMessages;
}
return currentMessages.filter(
(message) => !shouldRemoveRegenerateResponse(message, responseMessageId),
);
};
const mergeResponseMessage = (
candidateMessages: TMessage[],
updatedResponse: TMessage,
responseMessageId: string,
options?: { ensureUserMessage?: boolean },
) => {
const currentMessages = getResponseBaseMessages(
candidateMessages,
responseMessageId,
options?.ensureUserMessage === true,
);
const hasResponseMessage = currentMessages.some(
(msg) => msg.messageId === responseMessageId,
);
return hasResponseMessage
? currentMessages.map((msg) =>
msg.messageId === responseMessageId ? updatedResponse : msg,
)
: [...currentMessages, updatedResponse];
};
let parentMessageId =
submission.isRegenerate && submission.initialResponse?.parentMessageId
? submission.initialResponse.parentMessageId
: userMessage.messageId;
const currentTime = Date.now();
if (currentTime - lastAnnouncementTimeRef.current > MESSAGE_UPDATE_INTERVAL) {
@ -480,10 +562,13 @@ export default function useStepHandler({
let response = messageMap.current.get(responseMessageId);
if (!response) {
// Find the actual response message - check if last message is a response, otherwise use initialResponse
// Find the actual response message. Regenerate submissions can target
// an earlier branch while the visible history still ends at a later
// assistant message, so never seed a regenerated response from the
// conversation tail.
const lastMessage = messages[messages.length - 1] as TMessage;
const responseMessage =
lastMessage && !lastMessage.isCreatedByUser
!submission.isRegenerate && lastMessage && !lastMessage.isCreatedByUser
? lastMessage
: (submission?.initialResponse as TMessage);
@ -505,14 +590,20 @@ export default function useStepHandler({
// Get fresh messages to handle multi-tab scenarios where messages may have loaded
// after this handler started (Tab 2 may have more complete history now)
const freshMessages = getMessages() || [];
const currentMessages = freshMessages.length > messages.length ? freshMessages : messages;
const currentMessages = getResponseBaseMessages(messages, responseMessageId);
// Remove any existing response placeholder
let updatedMessages = currentMessages.filter((m) => m.messageId !== responseMessageId);
let updatedMessages = currentMessages.filter(
(message) => !shouldRemoveRegenerateResponse(message, responseMessageId),
);
// Ensure userMessage is present (multi-tab: Tab 2 may not have it yet)
if (!updatedMessages.some((m) => m.messageId === userMessage.messageId)) {
// Ensure userMessage is present (multi-tab: Tab 2 may not have it yet).
// Regenerate reuses an existing user turn; its submission userMessage is only
// a transport placeholder and must not become a new visible branch.
if (
!submission.isRegenerate &&
!updatedMessages.some((m) => m.messageId === userMessage.messageId)
) {
updatedMessages = [...updatedMessages, userMessage as TMessage];
}
@ -548,17 +639,11 @@ export default function useStepHandler({
});
messageMap.current.set(responseMessageId, updatedResponse);
const currentMessages = getCurrentMessages(messages);
const hasResponseMessage = currentMessages.some(
(msg) => msg.messageId === responseMessageId,
setMessages(
mergeResponseMessage(messages, updatedResponse, responseMessageId, {
ensureUserMessage: true,
}),
);
const updatedMessages = hasResponseMessage
? currentMessages.map((msg) =>
msg.messageId === responseMessageId ? updatedResponse : msg,
)
: [...currentMessages, updatedResponse];
setMessages(updatedMessages);
}
if (runStep.summary != null) {
@ -580,8 +665,8 @@ export default function useStepHandler({
);
messageMap.current.set(responseMessageId, updatedResponse);
const currentMessages = getMessages() || [];
setMessages([...currentMessages.slice(0, -1), updatedResponse]);
const currentMessages = submission.isRegenerate ? messages : getMessages() || [];
setMessages(mergeResponseMessage(currentMessages, updatedResponse, responseMessageId));
}
const bufferedDeltas = pendingDeltaBuffer.current.get(runStep.id);
@ -619,8 +704,8 @@ export default function useStepHandler({
agentUpdateMeta,
);
messageMap.current.set(responseMessageId, updatedResponse);
const currentMessages = getMessages() || [];
setMessages([...currentMessages.slice(0, -1), updatedResponse]);
const currentMessages = submission.isRegenerate ? messages : getMessages() || [];
setMessages(mergeResponseMessage(currentMessages, updatedResponse, responseMessageId));
}
} else if (stepEvent.event === StepEvents.ON_MESSAGE_DELTA) {
const messageDelta = stepEvent.data;
@ -662,8 +747,8 @@ export default function useStepHandler({
getStepMetadata(runStep),
);
messageMap.current.set(responseMessageId, updatedResponse);
const currentMessages = getMessages() || [];
setMessages([...currentMessages.slice(0, -1), updatedResponse]);
const currentMessages = submission.isRegenerate ? messages : getMessages() || [];
setMessages(mergeResponseMessage(currentMessages, updatedResponse, responseMessageId));
}
} else if (stepEvent.event === StepEvents.ON_REASONING_DELTA) {
const reasoningDelta = stepEvent.data;
@ -705,8 +790,8 @@ export default function useStepHandler({
getStepMetadata(runStep),
);
messageMap.current.set(responseMessageId, updatedResponse);
const currentMessages = getMessages() || [];
setMessages([...currentMessages.slice(0, -1), updatedResponse]);
const currentMessages = submission.isRegenerate ? messages : getMessages() || [];
setMessages(mergeResponseMessage(currentMessages, updatedResponse, responseMessageId));
}
} else if (stepEvent.event === StepEvents.ON_RUN_STEP_DELTA) {
const runStepDelta = stepEvent.data;
@ -761,17 +846,11 @@ export default function useStepHandler({
});
messageMap.current.set(responseMessageId, updatedResponse);
const currentMessages = getCurrentMessages(messages);
const hasResponseMessage = currentMessages.some(
(msg) => msg.messageId === responseMessageId,
setMessages(
mergeResponseMessage(messages, updatedResponse, responseMessageId, {
ensureUserMessage: true,
}),
);
const updatedMessages = hasResponseMessage
? currentMessages.map((msg) =>
msg.messageId === responseMessageId ? updatedResponse : msg,
)
: [...currentMessages, updatedResponse];
setMessages(updatedMessages);
}
} else if (stepEvent.event === StepEvents.ON_RUN_STEP_COMPLETED) {
const { result } = stepEvent.data;
@ -810,17 +889,11 @@ export default function useStepHandler({
);
messageMap.current.set(responseMessageId, updatedResponse);
const currentMessages = getCurrentMessages(messages);
const hasResponseMessage = currentMessages.some(
(msg) => msg.messageId === responseMessageId,
setMessages(
mergeResponseMessage(messages, updatedResponse, responseMessageId, {
ensureUserMessage: true,
}),
);
const updatedMessages = hasResponseMessage
? currentMessages.map((msg) =>
msg.messageId === responseMessageId ? updatedResponse : msg,
)
: [...currentMessages, updatedResponse];
setMessages(updatedMessages);
}
} else if (stepEvent.event === StepEvents.ON_SUBAGENT_UPDATE) {
applySubagentUpdate(stepEvent.data);
@ -863,8 +936,8 @@ export default function useStepHandler({
summarizeDeltaRaf.current = null;
const latest = messageMap.current.get(responseMessageId);
if (latest) {
const msgs = getMessages() || [];
setMessages([...msgs.slice(0, -1), latest]);
const currentMessages = submission.isRegenerate ? messages : getMessages() || [];
setMessages(mergeResponseMessage(currentMessages, latest, responseMessageId));
}
});
}
@ -882,8 +955,7 @@ export default function useStepHandler({
return;
}
const currentMessages = getMessages() || [];
const targetIndex = currentMessages.findIndex((m) => m.messageId === completeMessageId);
const currentMessages = submission.isRegenerate ? messages : getMessages() || [];
if (completeData.error) {
const filtered = targetMessage.content.filter(
@ -894,11 +966,7 @@ export default function useStepHandler({
announcePolite({ message: 'summarize_failed', isStatus: true });
const cleaned = { ...targetMessage, content: filtered };
messageMap.current.set(completeMessageId, cleaned);
if (targetIndex >= 0) {
const updated = [...currentMessages];
updated[targetIndex] = cleaned;
setMessages(updated);
}
setMessages(mergeResponseMessage(currentMessages, cleaned, completeMessageId));
}
} else {
let didFinalize = false;
@ -912,13 +980,11 @@ export default function useStepHandler({
}
return part;
});
if (didFinalize && targetIndex >= 0) {
if (didFinalize) {
announcePolite({ message: 'summarize_completed', isStatus: true });
const finalized = { ...targetMessage, content: updatedContent };
messageMap.current.set(completeMessageId, finalized);
const updated = [...currentMessages];
updated[targetIndex] = finalized;
setMessages(updated);
setMessages(mergeResponseMessage(currentMessages, finalized, completeMessageId));
}
}
} else {

View file

@ -21,6 +21,11 @@ export const STREAM_START_FAILED_METADATA_KEY = 'streamStartFailed';
type SiblingIndexLookup = (parentMessageId: string | null | undefined) => number;
export type BranchSiblingIndex = {
parentMessageId: string | null | undefined;
siblingIdx: number;
};
export const selectActiveBranchTail = (
messages: TMessage[] | null | undefined,
rootSiblingKey: string | null | undefined,
@ -83,6 +88,55 @@ export const getMessageBranchSiblingParentIds = (
return Array.from(parentIds);
};
export const getBranchSiblingIndexesForTarget = (
messages: TMessage[] | null | undefined,
targetMessageId: string | null | undefined,
rootSiblingKey: string | null | undefined,
): BranchSiblingIndex[] => {
if (!targetMessageId) {
return [];
}
const messagesTree = buildTree({ messages: messages ?? null });
if (!messagesTree?.length) {
return [];
}
const branchIndexes: BranchSiblingIndex[] = [];
const findTargetPath = (
siblings: TMessage[] | undefined,
parentMessageId: string | null | undefined,
): boolean => {
if (!siblings?.length) {
return false;
}
for (let index = 0; index < siblings.length; index++) {
const message = siblings[index];
if (!message) {
continue;
}
const isTarget = message.messageId === targetMessageId;
const childHasTarget = findTargetPath(message.children, message.messageId);
if (isTarget || childHasTarget) {
if (siblings.length > 1) {
branchIndexes.unshift({
parentMessageId,
siblingIdx: siblings.length - index - 1,
});
}
return true;
}
}
return false;
};
findTargetPath(messagesTree, rootSiblingKey);
return branchIndexes;
};
export const getLatestText = (message?: TMessage | null, includeIndex?: boolean): string => {
if (!message) {
return '';

View file

@ -36,6 +36,44 @@ test.describe('core chat loop', () => {
await expect(page.getByTestId('convo-item').first()).toBeVisible();
});
test('can switch back to the previous branch after regenerating an earlier response', async ({
page,
}) => {
test.setTimeout(90000);
const firstMessage = 'branch root from e2e';
const followUpMessage = 'follow-up on original branch from e2e';
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
let response = await sendMessage(page, firstMessage);
expect(response.ok()).toBeTruthy();
await expect(mockReply(page)).toBeVisible();
response = await sendMessage(page, followUpMessage);
expect(response.ok()).toBeTruthy();
await expect(page.getByText(followUpMessage)).toBeVisible();
const firstAssistantMessage = page
.getByTestId('messages-view')
.locator('.message-render')
.nth(1);
await firstAssistantMessage.hover();
const regenerateButton = firstAssistantMessage.locator('button[title="Regenerate"]').last();
await expect(regenerateButton).toBeVisible();
const [regenerateResponse] = await Promise.all([
page.waitForResponse(isAgentsStream, { timeout: 30000 }),
regenerateButton.click(),
]);
expect(regenerateResponse.ok()).toBeTruthy();
await expect(page.getByText('2 / 2')).toBeVisible();
await page.getByRole('button', { name: 'Previous sibling message' }).click();
await expect(page.getByText('1 / 2')).toBeVisible();
await expect(page.getByText(followUpMessage)).toBeVisible();
});
test('keeps upload-to-provider CSV attached to the sent message and model input', async ({
page,
}) => {

View file

@ -143,6 +143,105 @@ async function revisitConversationAndExpectMessages(
await expectVisibleMessages(page, texts);
}
async function mockActiveOAuthResumeStream({
page,
authUrl,
conversationId,
parentMessageId,
pendingPrompt,
pendingUserMessageId,
}: {
page: Page;
authUrl: string;
conversationId: string;
parentMessageId: string;
pendingPrompt: string;
pendingUserMessageId: string;
}) {
const pendingResponseMessageId = `${pendingUserMessageId}_`;
const toolCallId = `${pendingUserMessageId}:Google-Workspace`;
const stepId = 'step_oauth_login_Google-Workspace';
const resumeState = {
runSteps: [],
aggregatedContent: [],
responseMessageId: pendingResponseMessageId,
conversationId,
userMessage: {
messageId: pendingUserMessageId,
parentMessageId,
conversationId,
text: pendingPrompt,
},
replayEvents: [
{
event: 'on_run_step',
data: {
runId: 'USE_PRELIM_RESPONSE_MESSAGE_ID',
id: stepId,
type: 'tool_calls',
index: 0,
stepDetails: {
type: 'tool_calls',
tool_calls: [
{
id: toolCallId,
name: 'oauth_mcp_Google-Workspace',
type: 'tool_call_chunk',
},
],
},
},
},
{
event: 'on_run_step_delta',
data: {
id: stepId,
delta: {
type: 'tool_calls',
tool_calls: [
{
id: toolCallId,
name: 'oauth_mcp_Google-Workspace',
type: 'tool_call_chunk',
args: '',
},
],
auth: authUrl,
expires_at: Date.now() + 120000,
},
},
},
],
};
await page.route(`**/api/agents/chat/status/${conversationId}`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
active: true,
streamId: conversationId,
status: 'running',
aggregatedContent: [],
createdAt: Date.now(),
resumeState,
}),
}),
);
await page.route(`**/api/agents/chat/stream/${conversationId}**`, (route) =>
route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: `event: message\ndata: ${JSON.stringify({
sync: true,
resumeState,
pendingEvents: [],
})}\n\n`,
}),
);
}
async function openMockChat(page: Page) {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
@ -432,6 +531,53 @@ test.describe('message tree stream operations', () => {
expect(messages.some((message) => messageText(message).includes(followReply))).toBe(false);
});
test('resumes pending OAuth on the selected older branch after reload', async ({ page }) => {
const label = uniqueLabel('oauth-branch');
const rootPrompt = countedPrompt(`${label}-root`);
const firstReply = countedReplyText(`${label}-root`, 1);
const regeneratedReply = countedReplyText(`${label}-root`, 2);
const followPrompt = replyPrompt(`${label}-follow`);
const followReply = replyText(`${label}-follow`);
const pendingPrompt = replyPrompt(`${label}-oauth`);
await openMockChat(page);
await sendAndExpectReply(page, rootPrompt, firstReply);
const conversationId = await conversationIdFromPage(page);
await sendAndExpectReply(page, followPrompt, followReply);
await waitForGenerationStart(page, () =>
clickMessageTitleButton(page, firstReply, 'Regenerate'),
);
await expect(messagesView(page).getByText(regeneratedReply)).toBeVisible({ timeout: 30000 });
await clickSibling(page, regeneratedReply, 'Previous');
await expectVisibleMessages(page, [firstReply, followPrompt, followReply]);
await expect(messagesView(page).getByText(regeneratedReply)).toBeHidden();
const messages = await waitForMessages(
page,
conversationId,
(items) =>
items.some((message) => messageText(message).includes(followReply)) &&
items.some((message) => messageText(message).includes(regeneratedReply)),
'two-branch conversation',
);
const branchOneTail = findMessage(messages, followReply, false);
await mockActiveOAuthResumeStream({
page,
conversationId,
parentMessageId: branchOneTail.messageId,
pendingPrompt,
pendingUserMessageId: `${label}-pending-user`,
authUrl: `https://auth.example.test/${label}`,
});
await page.reload({ timeout: 10000 });
await expectVisibleMessages(page, [firstReply, followPrompt, followReply, pendingPrompt]);
await expect(messagesView(page).getByText(regeneratedReply)).toBeHidden();
});
test('long threads retain regenerated and save-and-submit branches after revisit', async ({
page,
}) => {

View file

@ -13,8 +13,8 @@ import {
ReauthenticationRequiredError,
resolveOboToken,
} from '~/mcp/oauth';
import { PENDING_STALE_MS, normalizeExpiresAt } from '~/flow/manager';
import { sanitizeUrlForLogging, isClientRejectionMessage, isOAuthServer } from './utils';
import { PENDING_STALE_MS, normalizeExpiresAt } from '~/flow/manager';
import { withTimeout } from '~/utils/promise';
import { MCPConnection } from './connection';
import { processMCPEnv } from '~/utils';
@ -46,7 +46,7 @@ export class MCPConnectionFactory {
protected readonly flowManager?: FlowStateManager<MCPOAuthTokens | null>;
protected readonly tokenMethods?: TokenMethods;
protected readonly signal?: AbortSignal;
protected readonly oauthStart?: (authURL: string) => Promise<void>;
protected readonly oauthStart?: t.OAuthStartHandler;
protected readonly oauthEnd?: () => Promise<void>;
protected readonly returnOnOAuth?: boolean;
protected readonly connectionTimeout?: number;
@ -484,6 +484,15 @@ export class MCPConnectionFactory {
};
}
private getOAuthReplayExpiresAt(createdAt?: number): number | undefined {
if (!createdAt) {
return undefined;
}
const expiresAt = createdAt + PENDING_STALE_MS;
return expiresAt > Date.now() ? expiresAt : undefined;
}
/** Sets up OAuth event handlers for the connection */
protected handleOAuthEvents(connection: MCPConnection): () => void {
const oauthHandler = async (data: { serverUrl?: string }) => {
@ -504,7 +513,24 @@ export class MCPConnectionFactory {
logger.debug(
`${this.logPrefix} Recent PENDING OAuth flow exists (${Math.round(pendingAge / 1000)}s old), skipping new initiation`,
);
connection.emit('oauthFailed', new Error('OAuth flow initiated - return early'));
const flowMeta = existingFlow.metadata as MCPOAuthFlowMetadata | undefined;
const storedAuthUrl = flowMeta?.authorizationUrl;
if (storedAuthUrl && typeof this.oauthStart === 'function') {
const expiresAt = this.getOAuthReplayExpiresAt(existingFlow.createdAt);
if (!expiresAt) {
logger.debug(`${this.logPrefix} PENDING OAuth flow expired before replay`);
connection.emit(
'oauthFailed',
new Error('Pending OAuth flow expired before replay'),
);
return;
}
logger.info(
`${this.logPrefix} Re-issuing stored authorization URL while reusing PENDING flow`,
);
await this.oauthStart(storedAuthUrl, { expiresAt });
}
connection.emit('oauthFailed', new Error('Pending OAuth flow reused - return early'));
return;
}
@ -771,10 +797,14 @@ export class MCPConnectionFactory {
const storedAuthUrl = flowMeta?.authorizationUrl;
if (storedAuthUrl && typeof this.oauthStart === 'function') {
const expiresAt = this.getOAuthReplayExpiresAt(existingFlow.createdAt);
if (!expiresAt) {
throw new Error('Pending OAuth flow expired before replay');
}
logger.info(
`${this.logPrefix} Re-issuing stored authorization URL to caller while joining PENDING flow`,
);
await this.oauthStart(storedAuthUrl);
await this.oauthStart(storedAuthUrl, { expiresAt });
}
reusedStoredClient = flowMeta?.reusedStoredClient === true;

View file

@ -10,18 +10,18 @@ import type { FlowStateManager } from '~/flow/manager';
import type { MCPOAuthTokens } from './oauth';
import type { RequestBody } from '~/types';
import type * as t from './types';
import { isUserSourced, requiresOAuthMachinery, requiresUserScopedConnection } from './utils';
import { MCPServersInitializer } from './registry/MCPServersInitializer';
import { OboTokenResolutionError, resolveOboToken } from '~/mcp/oauth';
import { MCPServerInspector } from './registry/MCPServerInspector';
import { MCPServersRegistry } from './registry/MCPServersRegistry';
import { UserConnectionManager } from './UserConnectionManager';
import { ConnectionsRepository } from './ConnectionsRepository';
import { MCPConnectionFactory } from './MCPConnectionFactory';
import { OboTokenResolutionError, resolveOboToken } from '~/mcp/oauth';
import { preProcessGraphTokens } from '~/utils/graph';
import { formatToolContent } from './parsers';
import { MCPConnection } from './connection';
import { processMCPEnv } from '~/utils/env';
import { isUserSourced, requiresOAuthMachinery, requiresUserScopedConnection } from './utils';
function createOboToolCallErrorMessage(
logPrefix: string,
@ -327,7 +327,7 @@ Please follow these instructions when using tools from the respective MCP server
tokenMethods?: TokenMethods;
customUserVars?: Record<string, string>;
flowManager: FlowStateManager<MCPOAuthTokens | null>;
oauthStart?: (authURL: string) => Promise<void>;
oauthStart?: t.OAuthStartHandler;
oauthEnd?: () => Promise<void>;
graphTokenResolver?: GraphTokenResolver;
oboTokenResolver?: OboTokenResolver;

View file

@ -1,13 +1,34 @@
import { logger } from '@librechat/data-schemas';
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import type { MCPOAuthFlowMetadata } from '~/mcp/oauth';
import type { FlowState } from '~/flow/types';
import type * as t from './types';
import { MCPServersRegistry } from '~/mcp/registry/MCPServersRegistry';
import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
import { isUserSourced, requiresOAuthMachinery } from './utils';
import { PENDING_STALE_MS } from '~/flow/manager';
import { MCPOAuthHandler } from '~/mcp/oauth';
import { MCPConnection } from './connection';
import { mcpConfig } from './mcpConfig';
type PendingOAuthStart = {
authURL: string;
options?: t.OAuthStartOptions;
};
type PendingOAuthState = {
oauthStarts: Set<t.OAuthStartHandler>;
emittedAuthUrls: WeakMap<t.OAuthStartHandler, string>;
primaryOAuthStart?: t.OAuthStartHandler;
lastOAuthStart?: PendingOAuthStart;
};
type PendingConnection = {
promise: Promise<MCPConnection>;
oauth: PendingOAuthState;
};
/**
* Abstract base class for managing user-specific MCP connections with lifecycle management.
* Only meant to be extended by MCPManager.
@ -23,7 +44,7 @@ export abstract class UserConnectionManager {
/** Last activity timestamp for users (not per server) */
protected userLastActivity: Map<string, number> = new Map();
/** In-flight connection promises keyed by `userId:serverName` — coalesces concurrent attempts */
protected pendingConnections: Map<string, Promise<MCPConnection>> = new Map();
protected pendingConnections: Map<string, PendingConnection> = new Map();
/** Updates the last activity timestamp for a user */
protected updateUserLastActivity(userId: string): void {
@ -48,25 +69,197 @@ export abstract class UserConnectionManager {
const pending = this.pendingConnections.get(lockKey);
if (pending) {
logger.debug(`[MCP][User: ${userId}][${serverName}] Joining in-flight connection attempt`);
return pending;
await this.addPendingOAuthStart(pending.oauth, opts, userId);
return pending.promise;
}
}
const connectionPromise = this.createUserConnectionInternal(opts, userId);
const pendingOAuth = this.createPendingOAuthState(opts.oauthStart);
const connectionPromise = this.createUserConnectionInternal(
{
...opts,
oauthStart: this.createPendingOAuthStart(serverName, userId, pendingOAuth),
},
userId,
);
if (!forceNew) {
this.pendingConnections.set(lockKey, connectionPromise);
this.pendingConnections.set(lockKey, { promise: connectionPromise, oauth: pendingOAuth });
}
try {
return await connectionPromise;
} finally {
if (!forceNew && this.pendingConnections.get(lockKey) === connectionPromise) {
if (!forceNew && this.pendingConnections.get(lockKey)?.promise === connectionPromise) {
this.pendingConnections.delete(lockKey);
}
}
}
private createPendingOAuthState(oauthStart?: t.OAuthStartHandler): PendingOAuthState {
return {
oauthStarts: oauthStart ? new Set([oauthStart]) : new Set(),
emittedAuthUrls: new WeakMap<t.OAuthStartHandler, string>(),
primaryOAuthStart: oauthStart,
};
}
private createPendingOAuthStart(
serverName: string,
userId: string,
pendingOAuth: PendingOAuthState,
): t.OAuthStartHandler {
return async (authURL, options) => {
pendingOAuth.lastOAuthStart = { authURL, options };
let primaryError: unknown;
const oauthStarts = Array.from(pendingOAuth.oauthStarts);
for (const oauthStart of oauthStarts) {
try {
await this.emitPendingOAuthStart(pendingOAuth, oauthStart, authURL, options);
} catch (error) {
if (oauthStart === pendingOAuth.primaryOAuthStart) {
primaryError = error;
} else {
logger.warn(
`[MCP][User: ${userId}][${serverName}] Failed to notify joined OAuth listener`,
error,
);
}
}
}
if (primaryError) {
throw primaryError;
}
};
}
private async addPendingOAuthStart(
pendingOAuth: PendingOAuthState,
opts: t.UserMCPConnectionOptions,
userId: string,
): Promise<void> {
const { oauthStart, serverName } = opts;
if (typeof oauthStart !== 'function') {
return;
}
pendingOAuth.oauthStarts.add(oauthStart);
const lastOAuthStart = pendingOAuth.lastOAuthStart;
if (lastOAuthStart) {
try {
const pendingOAuthStart =
lastOAuthStart.options?.expiresAt == null
? await this.getFlowPendingOAuthStart(opts, userId)
: undefined;
const replayOAuthStart =
pendingOAuthStart?.authURL === lastOAuthStart.authURL
? pendingOAuthStart
: lastOAuthStart;
await this.emitPendingOAuthStart(
pendingOAuth,
oauthStart,
replayOAuthStart.authURL,
replayOAuthStart.options,
);
} catch (error) {
logger.warn(
`[MCP][User: ${userId}][${serverName}] Failed to re-issue pending OAuth URL`,
error,
);
}
return;
}
await this.reissuePendingOAuthStart(opts, userId, pendingOAuth);
}
private async emitPendingOAuthStart(
pendingOAuth: PendingOAuthState,
oauthStart: t.OAuthStartHandler,
authURL: string,
options?: t.OAuthStartOptions,
): Promise<void> {
if (pendingOAuth.emittedAuthUrls.get(oauthStart) === authURL) {
return;
}
pendingOAuth.emittedAuthUrls.set(oauthStart, authURL);
await oauthStart(authURL, options);
}
private getPendingOAuthStart(flow: FlowState | null | undefined): PendingOAuthStart | undefined {
if (flow?.status !== 'PENDING') {
return undefined;
}
const expiresAt = flow.createdAt + PENDING_STALE_MS;
if (expiresAt <= Date.now()) {
return undefined;
}
const metadata = flow.metadata as MCPOAuthFlowMetadata | undefined;
const authorizationUrl = metadata?.authorizationUrl;
if (!authorizationUrl) {
return undefined;
}
return { authURL: authorizationUrl, options: { expiresAt } };
}
private async getFlowPendingOAuthStart(
{ flowManager, serverName }: Pick<t.UserMCPConnectionOptions, 'flowManager' | 'serverName'>,
userId: string,
): Promise<PendingOAuthStart | undefined> {
if (!flowManager) {
return undefined;
}
const flowId = MCPOAuthHandler.generateFlowId(userId, serverName);
const existingFlow = await flowManager.getFlowState(flowId, 'mcp_oauth');
return this.getPendingOAuthStart(existingFlow);
}
private async reissuePendingOAuthStart(
{ flowManager, oauthStart, serverName }: t.UserMCPConnectionOptions,
userId: string,
pendingOAuth?: PendingOAuthState,
): Promise<void> {
if (!flowManager || typeof oauthStart !== 'function') {
return;
}
try {
const pendingOAuthStart = await this.getFlowPendingOAuthStart(
{ flowManager, serverName },
userId,
);
if (!pendingOAuthStart) {
return;
}
logger.info(
`[MCP][User: ${userId}][${serverName}] Re-issuing stored authorization URL while joining in-flight connection`,
);
if (pendingOAuth) {
pendingOAuth.lastOAuthStart = pendingOAuthStart;
await this.emitPendingOAuthStart(
pendingOAuth,
oauthStart,
pendingOAuthStart.authURL,
pendingOAuthStart.options,
);
} else {
await oauthStart(pendingOAuthStart.authURL, pendingOAuthStart.options);
}
} catch (error) {
logger.warn(
`[MCP][User: ${userId}][${serverName}] Failed to re-issue pending OAuth URL`,
error,
);
}
}
private async createUserConnectionInternal(
{
serverName,

View file

@ -4,8 +4,9 @@ import type { FlowStateManager } from '~/flow/manager';
import type { MCPOAuthTokens } from '~/mcp/oauth';
import type * as t from '~/mcp/types';
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
import { MCPConnection } from '~/mcp/connection';
import { MCPOAuthHandler, MCPTokenStorage } from '~/mcp/oauth';
import { PENDING_STALE_MS } from '~/flow/manager';
import { MCPConnection } from '~/mcp/connection';
import { processMCPEnv } from '~/utils';
jest.mock('~/mcp/connection');
@ -398,7 +399,7 @@ describe('MCPConnectionFactory', () => {
);
});
it('should skip new OAuth flow initiation when a PENDING flow already exists (returnOnOAuth)', async () => {
it('should re-issue the stored OAuth URL when a PENDING flow already exists (returnOnOAuth)', async () => {
const basicOptions = {
serverName: 'test-server',
serverConfig: mockServerConfig,
@ -416,7 +417,10 @@ describe('MCPConnectionFactory', () => {
mockFlowManager.getFlowState.mockResolvedValue({
status: 'PENDING',
type: 'mcp_oauth',
metadata: { codeVerifier: 'existing-verifier' },
metadata: {
codeVerifier: 'existing-verifier',
authorizationUrl: 'https://auth.example.com/existing',
},
createdAt: Date.now(),
});
mockConnectionInstance.isConnected.mockResolvedValue(false);
@ -438,10 +442,73 @@ describe('MCPConnectionFactory', () => {
await oauthRequiredHandler!({ serverUrl: 'https://api.example.com' });
expect(mockMCPOAuthHandler.initiateOAuthFlow).not.toHaveBeenCalled();
expect(oauthOptions.oauthStart).toHaveBeenCalledWith(
'https://auth.example.com/existing',
expect.objectContaining({ expiresAt: expect.any(Number) }),
);
expect(mockFlowManager.deleteFlow).not.toHaveBeenCalled();
expect(mockConnectionInstance.emit).toHaveBeenCalledWith(
'oauthFailed',
expect.objectContaining({ message: 'OAuth flow initiated - return early' }),
expect.objectContaining({ message: 'Pending OAuth flow reused - return early' }),
);
});
it('should emit oauthFailed when a PENDING flow expires before replay (returnOnOAuth)', async () => {
const createdAt = 1000;
const firstNow = createdAt + PENDING_STALE_MS - 1;
const expiredNow = createdAt + PENDING_STALE_MS;
jest
.spyOn(Date, 'now')
.mockReturnValueOnce(firstNow)
.mockReturnValueOnce(expiredNow)
.mockReturnValue(expiredNow);
const basicOptions = {
serverName: 'test-server',
serverConfig: mockServerConfig,
user: mockUser,
};
const oauthOptions: t.OAuthConnectionOptions = {
user: mockUser,
useOAuth: true,
returnOnOAuth: true,
oauthStart: jest.fn(),
flowManager: mockFlowManager,
};
mockFlowManager.getFlowState.mockResolvedValue({
status: 'PENDING',
type: 'mcp_oauth',
metadata: {
codeVerifier: 'existing-verifier',
authorizationUrl: 'https://auth.example.com/existing',
},
createdAt,
});
mockConnectionInstance.isConnected.mockResolvedValue(false);
let oauthRequiredHandler: (data: Record<string, unknown>) => Promise<void>;
mockConnectionInstance.on.mockImplementation((event, handler) => {
if (event === 'oauthRequired') {
oauthRequiredHandler = handler as (data: Record<string, unknown>) => Promise<void>;
}
return mockConnectionInstance;
});
try {
await MCPConnectionFactory.create(basicOptions, oauthOptions);
} catch {
// Expected to fail
}
await oauthRequiredHandler!({ serverUrl: 'https://api.example.com' });
expect(oauthOptions.oauthStart).not.toHaveBeenCalled();
expect(mockMCPOAuthHandler.initiateOAuthFlow).not.toHaveBeenCalled();
expect(mockConnectionInstance.emit).toHaveBeenCalledWith(
'oauthFailed',
expect.objectContaining({ message: 'Pending OAuth flow expired before replay' }),
);
});

View file

@ -45,6 +45,7 @@ const mockLogger = logger as jest.Mocked<typeof logger>;
describe('MCP OAuth Race Condition Fixes', () => {
afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
describe('Fix 1: Connection mutex coalesces concurrent attempts', () => {
@ -77,7 +78,7 @@ describe('MCP OAuth Race Condition Fixes', () => {
dbId: undefined,
};
jest
const registrySpy = jest
.spyOn(
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('~/mcp/registry/MCPServersRegistry').MCPServersRegistry,
@ -97,27 +98,213 @@ describe('MCP OAuth Race Condition Fixes', () => {
return mockConnection as never;
});
const store = new MockKeyv();
const flowManager = new FlowStateManager(store as unknown as Keyv, { ttl: 30000, ci: true });
const user = { id: 'user-1' };
const opts = {
serverName: 'test-server',
user: user as never,
flowManager: flowManager as never,
try {
const store = new MockKeyv();
const flowManager = new FlowStateManager(store as unknown as Keyv, {
ttl: 30000,
ci: true,
});
const user = { id: 'user-1' };
const opts = {
serverName: 'test-server',
user: user as never,
flowManager: flowManager as never,
};
const [conn1, conn2, conn3] = await Promise.all([
manager.getUserConnection(opts),
manager.getUserConnection(opts),
manager.getUserConnection(opts),
]);
expect(conn1).toBe(conn2);
expect(conn2).toBe(conn3);
expect(createSpy).toHaveBeenCalledTimes(1);
expect(manager.createCallCount).toBe(1);
} finally {
createSpy.mockRestore();
registrySpy.mockRestore();
}
});
it('should re-issue the pending OAuth URL when joining an in-flight connection', async () => {
const { UserConnectionManager } = await import('~/mcp/UserConnectionManager');
class TestManager extends UserConnectionManager {}
const manager = new TestManager();
const mockConnection = {
isConnected: jest.fn().mockResolvedValue(true),
disconnect: jest.fn().mockResolvedValue(undefined),
isStale: jest.fn().mockReturnValue(false),
};
const [conn1, conn2, conn3] = await Promise.all([
manager.getUserConnection(opts),
manager.getUserConnection(opts),
manager.getUserConnection(opts),
]);
const mockAppConnections = { has: jest.fn().mockResolvedValue(false) };
manager.appConnections = mockAppConnections as never;
expect(conn1).toBe(conn2);
expect(conn2).toBe(conn3);
expect(createSpy).toHaveBeenCalledTimes(1);
expect(manager.createCallCount).toBe(1);
const mockConfig = {
type: 'streamable-http',
url: 'http://localhost:9999/',
updatedAt: undefined,
dbId: undefined,
};
createSpy.mockRestore();
const registrySpy = jest
.spyOn(
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('~/mcp/registry/MCPServersRegistry').MCPServersRegistry,
'getInstance',
)
.mockReturnValue({
getServerConfig: jest.fn().mockResolvedValue(mockConfig),
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
});
const { MCPConnectionFactory } = await import('~/mcp/MCPConnectionFactory');
const createSpy = jest.spyOn(MCPConnectionFactory, 'create').mockImplementation(async () => {
await new Promise((r) => setTimeout(r, 100));
return mockConnection as never;
});
try {
const store = new MockKeyv();
const flowManager = new FlowStateManager(store as unknown as Keyv, {
ttl: 30000,
ci: true,
});
const user = { id: 'user-oauth' };
const serverName = 'test-server';
const authorizationUrl = 'https://auth.example.com/existing';
await flowManager.initFlow(`${user.id}:${serverName}`, 'mcp_oauth', { authorizationUrl });
const firstConnection = manager.getUserConnection({
serverName,
user: user as never,
flowManager: flowManager as never,
});
const oauthStart = jest.fn().mockResolvedValue(undefined);
const joinedConnection = manager.getUserConnection({
serverName,
user: user as never,
flowManager: flowManager as never,
oauthStart,
});
const [conn1, conn2] = await Promise.all([firstConnection, joinedConnection]);
expect(conn1).toBe(conn2);
expect(oauthStart).toHaveBeenCalledWith(
authorizationUrl,
expect.objectContaining({ expiresAt: expect.any(Number) }),
);
expect(createSpy).toHaveBeenCalledTimes(1);
} finally {
createSpy.mockRestore();
registrySpy.mockRestore();
}
});
it('should preserve pending OAuth expiry when joining after the prompt was emitted', async () => {
const { UserConnectionManager } = await import('~/mcp/UserConnectionManager');
class TestManager extends UserConnectionManager {}
const manager = new TestManager();
const mockConnection = {
isConnected: jest.fn().mockResolvedValue(true),
disconnect: jest.fn().mockResolvedValue(undefined),
isStale: jest.fn().mockReturnValue(false),
};
const mockAppConnections = { has: jest.fn().mockResolvedValue(false) };
manager.appConnections = mockAppConnections as never;
const mockConfig = {
type: 'streamable-http',
url: 'http://localhost:9999/',
requiresOAuth: true,
updatedAt: undefined,
dbId: undefined,
};
const registrySpy = jest
.spyOn(
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('~/mcp/registry/MCPServersRegistry').MCPServersRegistry,
'getInstance',
)
.mockReturnValue({
getServerConfig: jest.fn().mockResolvedValue(mockConfig),
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
});
let releaseConnection: () => void = () => undefined;
const connectionReleased = new Promise<void>((resolve) => {
releaseConnection = resolve;
});
const { MCPConnectionFactory } = await import('~/mcp/MCPConnectionFactory');
const authorizationUrl = 'https://auth.example.com/current';
const createSpy = jest
.spyOn(MCPConnectionFactory, 'create')
.mockImplementation(async (_basicOptions, oauthOptions) => {
if (oauthOptions && 'oauthStart' in oauthOptions) {
await oauthOptions.oauthStart?.(authorizationUrl);
}
await connectionReleased;
return mockConnection as never;
});
try {
const store = new MockKeyv();
const flowManager = new FlowStateManager(store as unknown as Keyv, {
ttl: 30000,
ci: true,
});
const user = { id: 'user-oauth-emitted' };
const serverName = 'test-server';
await flowManager.initFlow(`${user.id}:${serverName}`, 'mcp_oauth', { authorizationUrl });
const firstOAuthStart = jest.fn().mockResolvedValue(undefined);
const firstConnection = manager.getUserConnection({
serverName,
user: user as never,
flowManager: flowManager as never,
oauthStart: firstOAuthStart,
});
for (let i = 0; i < 20 && firstOAuthStart.mock.calls.length === 0; i++) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
expect(firstOAuthStart).toHaveBeenCalledWith(authorizationUrl, undefined);
const joinedOAuthStart = jest.fn().mockResolvedValue(undefined);
const joinedConnection = manager.getUserConnection({
serverName,
user: user as never,
flowManager: flowManager as never,
oauthStart: joinedOAuthStart,
});
releaseConnection();
const [conn1, conn2] = await Promise.all([firstConnection, joinedConnection]);
expect(conn1).toBe(conn2);
expect(joinedOAuthStart).toHaveBeenCalledWith(
authorizationUrl,
expect.objectContaining({ expiresAt: expect.any(Number) }),
);
expect(createSpy).toHaveBeenCalledTimes(1);
} finally {
releaseConnection();
createSpy.mockRestore();
registrySpy.mockRestore();
}
});
it('should not coalesce when forceNew is true', async () => {
@ -144,7 +331,7 @@ describe('MCP OAuth Race Condition Fixes', () => {
dbId: undefined,
};
jest
const registrySpy = jest
.spyOn(
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('~/mcp/registry/MCPServersRegistry').MCPServersRegistry,
@ -158,33 +345,41 @@ describe('MCP OAuth Race Condition Fixes', () => {
});
const { MCPConnectionFactory } = await import('~/mcp/MCPConnectionFactory');
jest.spyOn(MCPConnectionFactory, 'create').mockImplementation(async () => {
const createSpy = jest.spyOn(MCPConnectionFactory, 'create').mockImplementation(async () => {
callCount++;
await new Promise((r) => setTimeout(r, 50));
return makeConnection() as never;
});
const store = new MockKeyv();
const flowManager = new FlowStateManager(store as unknown as Keyv, { ttl: 30000, ci: true });
const user = { id: 'user-2' };
try {
const store = new MockKeyv();
const flowManager = new FlowStateManager(store as unknown as Keyv, {
ttl: 30000,
ci: true,
});
const user = { id: 'user-2' };
const [conn1, conn2] = await Promise.all([
manager.getUserConnection({
serverName: 'test-server',
forceNew: true,
user: user as never,
flowManager: flowManager as never,
}),
manager.getUserConnection({
serverName: 'test-server',
forceNew: true,
user: user as never,
flowManager: flowManager as never,
}),
]);
const [conn1, conn2] = await Promise.all([
manager.getUserConnection({
serverName: 'test-server',
forceNew: true,
user: user as never,
flowManager: flowManager as never,
}),
manager.getUserConnection({
serverName: 'test-server',
forceNew: true,
user: user as never,
flowManager: flowManager as never,
}),
]);
expect(callCount).toBe(2);
expect(conn1).not.toBe(conn2);
expect(callCount).toBe(2);
expect(conn1).not.toBe(conn2);
} finally {
createSpy.mockRestore();
registrySpy.mockRestore();
}
});
});

View file

@ -0,0 +1,158 @@
import { Constants, Time } from 'librechat-data-provider';
import { GraphEvents, StepTypes } from '@librechat/agents';
import type * as t from '~/types';
import { buildOAuthToolCallName } from '~/mcp/utils';
export type OAuthPromptOptions = {
expiresAt?: number;
};
export type OAuthToolCall = {
id?: string;
name: string;
type: 'tool_call' | 'tool_call_chunk';
args?: string;
output?: string;
};
export function getOAuthPromptExpiresAt(options?: OAuthPromptOptions, now = Date.now()): number {
return typeof options?.expiresAt === 'number' && Number.isFinite(options.expiresAt)
? options.expiresAt
: now + Time.TWO_MINUTES;
}
export function getMCPServerNamesFromTools(tools?: unknown[] | null): Set<string> {
const serverNames = new Set<string>();
for (const tool of tools ?? []) {
if (typeof tool !== 'string') {
continue;
}
const delimiterIndex = tool.indexOf(Constants.mcp_delimiter);
if (delimiterIndex === -1) {
continue;
}
serverNames.add(tool.slice(delimiterIndex + Constants.mcp_delimiter.length));
}
return serverNames;
}
export function buildMCPAuthStepId(serverName: string): string {
return `step_oauth_login_${serverName}`;
}
export function buildMCPAuthToolCall({
id,
args,
output,
serverName,
type = 'tool_call_chunk',
}: {
id?: string;
args?: string;
output?: string;
serverName: string;
type?: OAuthToolCall['type'];
}): OAuthToolCall {
return {
...(id != null ? { id } : {}),
name: buildOAuthToolCallName(serverName),
...(args != null ? { args } : {}),
...(output != null ? { output } : {}),
type,
};
}
export function buildMCPAuthRunStepEvent({
runId = Constants.USE_PRELIM_RESPONSE_MESSAGE_ID,
stepId,
toolCall,
index = 0,
}: {
runId?: string | null;
stepId: string;
toolCall: OAuthToolCall;
index?: number;
}): t.ServerSentEvent {
return {
event: GraphEvents.ON_RUN_STEP,
data: {
runId,
id: stepId,
type: StepTypes.TOOL_CALLS,
index,
stepDetails: {
type: StepTypes.TOOL_CALLS,
tool_calls: [toolCall],
},
},
};
}
export function buildMCPAuthRunStepDeltaEvent({
authURL,
stepId,
toolCall,
options,
}: {
authURL: string;
stepId: string;
toolCall: OAuthToolCall;
options?: OAuthPromptOptions;
}): t.ServerSentEvent {
return {
event: GraphEvents.ON_RUN_STEP_DELTA,
data: {
id: stepId,
delta: {
type: StepTypes.TOOL_CALLS,
tool_calls: [{ ...toolCall, args: '' }],
auth: authURL,
expires_at: getOAuthPromptExpiresAt(options),
},
},
};
}
export function buildMCPAuthRunStepEndDeltaEvent({
stepId,
toolCall,
}: {
stepId: string;
toolCall: OAuthToolCall;
}): t.ServerSentEvent {
return {
event: GraphEvents.ON_RUN_STEP_DELTA,
data: {
id: stepId,
delta: {
type: StepTypes.TOOL_CALLS,
tool_calls: [{ ...toolCall }],
},
},
};
}
export function buildMCPAuthRunStepCompletedEvent({
stepId,
toolCall,
index = 0,
}: {
stepId: string;
toolCall: OAuthToolCall;
index?: number;
}): t.ServerSentEvent {
return {
event: GraphEvents.ON_RUN_STEP_COMPLETED,
data: {
result: {
id: stepId,
index,
tool_call: toolCall,
},
},
};
}

View file

@ -4,3 +4,5 @@ export * from './tokens';
export * from './detectOAuth';
export * from './methods';
export * from './obo';
export * from './pending';
export * from './events';

View file

@ -0,0 +1,70 @@
import { logger } from '@librechat/data-schemas';
import type { MCPOAuthFlowMetadata, MCPOAuthTokens } from './types';
import type { FlowStateManager } from '~/flow/manager';
import type { FlowState } from '~/flow/types';
import type * as t from '~/mcp/types';
import { PENDING_STALE_MS } from '~/flow/manager';
import { MCPOAuthHandler } from './handler';
const MCP_OAUTH_FLOW_TYPE = 'mcp_oauth';
type PendingOAuthFlowState = Pick<
FlowState<MCPOAuthTokens | null>,
'createdAt' | 'metadata' | 'status'
>;
type PendingOAuthFlowManager = Pick<FlowStateManager<MCPOAuthTokens | null>, 'getFlowState'>;
export type PendingOAuthStart = {
authURL: string;
options?: t.OAuthStartOptions;
};
export type ReplayablePendingMCPOAuthStartOptions = {
flowManager?: PendingOAuthFlowManager | null;
userId: string;
serverName: string;
};
export function getReplayablePendingMCPOAuthStartFromFlow(
flow: PendingOAuthFlowState | null | undefined,
now = Date.now(),
): PendingOAuthStart | undefined {
if (flow?.status !== 'PENDING') {
return undefined;
}
const expiresAt = flow.createdAt + PENDING_STALE_MS;
if (expiresAt <= now) {
return undefined;
}
const metadata = flow.metadata as Partial<MCPOAuthFlowMetadata>;
const authorizationUrl = metadata.authorizationUrl;
if (!authorizationUrl) {
return undefined;
}
return { authURL: authorizationUrl, options: { expiresAt } };
}
export async function getReplayablePendingMCPOAuthStart({
flowManager,
userId,
serverName,
}: ReplayablePendingMCPOAuthStartOptions): Promise<PendingOAuthStart | undefined> {
if (!flowManager || typeof flowManager.getFlowState !== 'function') {
return undefined;
}
try {
const flowId = MCPOAuthHandler.generateFlowId(userId, serverName);
const flowState = await flowManager.getFlowState(flowId, MCP_OAUTH_FLOW_TYPE);
return getReplayablePendingMCPOAuthStartFromFlow(flowState);
} catch (error) {
logger.warn(`[MCP OAuth] Failed to inspect pending flow for ${serverName}:`, error);
return undefined;
}
}

View file

@ -19,10 +19,10 @@ import type {
import type { SearchResultData, UIResource, TPlugin } from 'librechat-data-provider';
import type { TokenMethods, IUser } from '@librechat/data-schemas';
import type { LCTool } from '@librechat/agents';
import type { OboTokenResolver, OboTrustChecker } from '~/mcp/oauth/obo';
import type { FlowStateManager } from '~/flow/manager';
import type { RequestBody } from '~/types/http';
import type * as o from '~/mcp/oauth/types';
import type { OboTokenResolver, OboTrustChecker } from '~/mcp/oauth/obo';
export type StdioOptions = z.infer<typeof StdioOptionsSchema>;
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
@ -201,12 +201,18 @@ export interface UserConnectionContext {
connectionTimeout?: number;
}
export interface OAuthStartOptions {
expiresAt?: number;
}
export type OAuthStartHandler = (authURL: string, options?: OAuthStartOptions) => Promise<void>;
export interface OAuthConnectionOptions extends UserConnectionContext {
useOAuth: true;
flowManager: FlowStateManager<o.MCPOAuthTokens | null>;
tokenMethods?: TokenMethods;
signal?: AbortSignal;
oauthStart?: (authURL: string) => Promise<void>;
oauthStart?: OAuthStartHandler;
oauthEnd?: () => Promise<void>;
returnOnOAuth?: boolean;
oboTokenResolver?: OboTokenResolver;
@ -221,7 +227,7 @@ export interface UserMCPConnectionOptions extends UserConnectionContext {
flowManager?: FlowStateManager<o.MCPOAuthTokens | null>;
tokenMethods?: TokenMethods;
signal?: AbortSignal;
oauthStart?: (authURL: string) => Promise<void>;
oauthStart?: OAuthStartHandler;
oauthEnd?: () => Promise<void>;
returnOnOAuth?: boolean;
oboTokenResolver?: OboTokenResolver;
@ -234,7 +240,7 @@ export interface ToolDiscoveryOptions {
flowManager?: FlowStateManager<o.MCPOAuthTokens | null>;
tokenMethods?: TokenMethods;
signal?: AbortSignal;
oauthStart?: (authURL: string) => Promise<void>;
oauthStart?: OAuthStartHandler;
customUserVars?: Record<string, string>;
requestBody?: RequestBody;
connectionTimeout?: number;

View file

@ -1,7 +1,7 @@
import { Constants, parseTextParts } from 'librechat-data-provider';
import { logger, getTenantId, SYSTEM_TENANT_ID } from '@librechat/data-schemas';
import type { StandardGraph } from '@librechat/agents';
import { parseTextParts } from 'librechat-data-provider';
import type { Agents, TMessageContentParts } from 'librechat-data-provider';
import type { StandardGraph } from '@librechat/agents';
import type {
SerializableJobData,
IEventTransport,
@ -9,6 +9,7 @@ import type {
AbortResult,
IJobStore,
} from './interfaces/IJobStore';
import type { GenerationJobStore } from '~/app/metrics';
import type * as t from '~/types';
import {
recordGenerationJob,
@ -16,12 +17,85 @@ import {
recordGenerationStreamSubscription,
setGenerationJobsInFlight,
} from '~/app/metrics';
import type { GenerationJobStore } from '~/app/metrics';
import { InMemoryEventTransport } from './implementations/InMemoryEventTransport';
import { InMemoryJobStore } from './implementations/InMemoryJobStore';
import { filterPersistableAbortContent } from './abortContent';
/** Error surfaced to any client still attached when a stale/hung job is reaped. */
const REAPED_JOB_ERROR = 'Generation timed out';
const OAUTH_TOOL_CALL_PREFIX = `oauth${Constants.mcp_delimiter}`;
function getToolCallName(toolCall: unknown): unknown {
return toolCall != null && typeof toolCall === 'object' && 'name' in toolCall
? toolCall.name
: undefined;
}
function hasOAuthToolCall(toolCalls: unknown): boolean {
return (
Array.isArray(toolCalls) &&
toolCalls.some((toolCall) => {
const name = getToolCallName(toolCall);
return typeof name === 'string' && name.startsWith(OAUTH_TOOL_CALL_PREFIX);
})
);
}
function getReplayStepId(event: t.ServerSentEvent): unknown {
if (!('event' in event) || !event.data || typeof event.data !== 'object') {
return undefined;
}
if (event.event === 'on_run_step' || event.event === 'on_run_step_delta') {
return 'id' in event.data ? event.data.id : undefined;
}
if (event.event === 'on_run_step_completed') {
const result = 'result' in event.data ? event.data.result : undefined;
return result != null && typeof result === 'object' && 'id' in result ? result.id : undefined;
}
return undefined;
}
function isOAuthReplayEvent(event: t.ServerSentEvent): boolean {
if (!('event' in event) || !event.data || typeof event.data !== 'object') {
return false;
}
if (event.event === 'on_run_step') {
const stepDetails = 'stepDetails' in event.data ? event.data.stepDetails : undefined;
return (
stepDetails != null &&
typeof stepDetails === 'object' &&
'tool_calls' in stepDetails &&
hasOAuthToolCall(stepDetails.tool_calls)
);
}
if (event.event === 'on_run_step_delta') {
const delta = 'delta' in event.data ? event.data.delta : undefined;
if (delta == null || typeof delta !== 'object') {
return false;
}
if (!('tool_calls' in delta) || !hasOAuthToolCall(delta.tool_calls)) {
return false;
}
return true;
}
if (event.event === 'on_run_step_completed') {
const result = 'result' in event.data ? event.data.result : undefined;
if (result == null || typeof result !== 'object' || !('tool_call' in result)) {
return false;
}
const name = getToolCallName(result.tool_call);
return typeof name === 'string' && name.startsWith(OAUTH_TOOL_CALL_PREFIX);
}
return false;
}
/**
* Configuration options for GenerationJobManager
@ -100,6 +174,9 @@ class GenerationJobManagerClass {
/** Jobs actively generating in this process. */
private runningJobs = new Set<string>();
/** Serializes replay-event read/modify/write updates per stream. */
private replayEventWriteQueues = new Map<string, Promise<void>>();
private cleanupInterval: NodeJS.Timeout | null = null;
/** Whether we're using Redis stores */
@ -567,6 +644,7 @@ class GenerationJobManagerClass {
// Clear content state and run step buffer (Redis only)
this.jobStore.clearContentState(streamId);
this.runStepBuffers?.delete(streamId);
this.replayEventWriteQueues.delete(streamId);
// For error jobs, DON'T delete immediately - keep around so late-connecting
// clients can receive the error. This handles the race condition where error
@ -652,16 +730,20 @@ class GenerationJobManagerClass {
/** Content before clearing state */
const result = await this.jobStore.getContentParts(streamId);
const content = result?.content ?? [];
const abortContent = filterPersistableAbortContent(content);
const shouldPersistAbortContent = abortContent.length > 0;
/** Collected usage for all models */
const collectedUsage = this.jobStore.getCollectedUsage(streamId);
/** Text from content parts for fallback token counting */
const text = parseTextParts(content as TMessageContentParts[]);
const text = shouldPersistAbortContent
? parseTextParts(abortContent as TMessageContentParts[])
: '';
/** Detect "early abort" - aborted before any generation happened (e.g., during tool loading)
In this case, no messages were saved to DB, so frontend shouldn't navigate to conversation */
const isEarlyAbort = content.length === 0 && !jobData.responseMessageId;
const isEarlyAbort = !shouldPersistAbortContent && jobData.createdEventEmitted !== true;
/** Final event for abort */
const userMessageId = jobData.userMessage?.messageId;
@ -686,7 +768,7 @@ class GenerationJobManagerClass {
messageId: jobData.responseMessageId ?? `${userMessageId ?? 'aborted'}_`,
parentMessageId: userMessageId,
conversationId: jobData.conversationId,
content,
content: abortContent,
sender: jobData.sender ?? 'AI',
unfinished: true,
error: false,
@ -704,6 +786,7 @@ class GenerationJobManagerClass {
await this.eventTransport.emitDone(streamId, abortFinalEvent);
this.jobStore.clearContentState(streamId);
this.runStepBuffers?.delete(streamId);
this.replayEventWriteQueues.delete(streamId);
// Immediate cleanup if configured (default: true)
if (this._cleanupOnComplete) {
@ -726,7 +809,7 @@ class GenerationJobManagerClass {
return {
success: true,
jobData,
content,
content: abortContent,
finalEvent: abortFinalEvent,
text,
collectedUsage,
@ -962,6 +1045,7 @@ class GenerationJobManagerClass {
await this.trackUserMessage(streamId, event);
await this.trackTitleEvent(streamId, event);
await this.trackReplayEvent(streamId, event);
// For Redis mode, persist chunk for later reconstruction (fire-and-forget for resumability)
if (this._isRedis) {
@ -1059,6 +1143,71 @@ class GenerationJobManagerClass {
});
}
/**
* Persist replay-only stream events that are needed to reconstruct active
* UI state on resume but are not represented by aggregated message content.
*/
private async trackReplayEvent(streamId: string, event: t.ServerSentEvent): Promise<void> {
if (!isOAuthReplayEvent(event)) {
return;
}
const previousWrite = this.replayEventWriteQueues.get(streamId) ?? Promise.resolve();
const nextWrite = previousWrite
.catch(() => {
// Keep the queue moving even if a prior replay metadata write failed.
})
.then(() => this.persistReplayEvent(streamId, event));
this.replayEventWriteQueues.set(streamId, nextWrite);
try {
await nextWrite;
} finally {
if (this.replayEventWriteQueues.get(streamId) === nextWrite) {
this.replayEventWriteQueues.delete(streamId);
}
}
}
private async persistReplayEvent(streamId: string, event: t.ServerSentEvent): Promise<void> {
const jobData = await this.jobStore.getJob(streamId);
if (!jobData) {
return;
}
let replayEvents: t.ServerSentEvent[] = [];
if (jobData.replayEvents) {
try {
replayEvents = JSON.parse(jobData.replayEvents) as t.ServerSentEvent[];
} catch {
replayEvents = [];
}
}
const stepId = getReplayStepId(event);
const eventName = 'event' in event ? event.event : undefined;
const existingIndex =
stepId == null
? -1
: replayEvents.findIndex((candidate) => {
if (!('event' in candidate) || candidate.event !== eventName) {
return false;
}
return getReplayStepId(candidate) === stepId;
});
if (existingIndex >= 0) {
replayEvents[existingIndex] = event;
} else {
replayEvents.push(event);
}
await this.jobStore.updateJob(streamId, {
replayEvents: JSON.stringify(replayEvents),
});
}
/**
* Persist user message metadata from the created event.
* Awaited in emitChunk so the HSET commits before the PUBLISH,
@ -1072,6 +1221,7 @@ class GenerationJobManagerClass {
const { message } = event;
const updates: Partial<SerializableJobData> = {
createdEventEmitted: true,
userMessage: {
messageId: message.messageId,
parentMessageId: message.parentMessageId,
@ -1176,6 +1326,14 @@ class GenerationJobManagerClass {
// Ignore malformed persisted title events.
}
}
let replayEvents: t.ResumeState['replayEvents'];
if (jobData.replayEvents) {
try {
replayEvents = JSON.parse(jobData.replayEvents) as t.ResumeState['replayEvents'];
} catch {
// Ignore malformed persisted replay events.
}
}
logger.debug(`[GenerationJobManager] getResumeState:`, {
streamId,
@ -1191,6 +1349,7 @@ class GenerationJobManagerClass {
conversationId: jobData.conversationId,
sender: jobData.sender,
titleEvent,
replayEvents,
};
}
@ -1407,6 +1566,7 @@ class GenerationJobManagerClass {
this.runningJobs.clear();
this.syncRunningJobMetrics();
this.runStepBuffers?.clear();
this.replayEventWriteQueues.clear();
logger.debug('[GenerationJobManager] Destroyed');
}

View file

@ -0,0 +1,218 @@
import type { ServerSentEvent } from '~/types';
import { InMemoryEventTransport } from '~/stream/implementations/InMemoryEventTransport';
import { InMemoryJobStore } from '~/stream/implementations/InMemoryJobStore';
import { GenerationJobManagerClass } from '~/stream/GenerationJobManager';
jest.spyOn(console, 'log').mockImplementation();
function createInMemoryManager(): GenerationJobManagerClass {
const manager = new GenerationJobManagerClass();
manager.configure({
jobStore: new InMemoryJobStore({ ttlAfterComplete: 60000 }),
eventTransport: new InMemoryEventTransport(),
isRedis: false,
});
manager.initialize();
return manager;
}
class SnapshotReplayJobStore extends InMemoryJobStore {
async getJob(streamId: string) {
const job = await super.getJob(streamId);
return job ? { ...job } : null;
}
async updateJob(streamId: string, updates: Parameters<InMemoryJobStore['updateJob']>[1]) {
if (updates.replayEvents) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
await super.updateJob(streamId, updates);
}
}
function createSnapshotReplayManager(): GenerationJobManagerClass {
const manager = new GenerationJobManagerClass();
manager.configure({
jobStore: new SnapshotReplayJobStore({ ttlAfterComplete: 60000 }),
eventTransport: new InMemoryEventTransport(),
isRedis: false,
});
manager.initialize();
return manager;
}
describe('GenerationJobManager resume replay events', () => {
let manager: GenerationJobManagerClass | undefined;
afterEach(async () => {
await manager?.destroy();
manager = undefined;
});
test('includes OAuth run step and delta replay events in resume state', async () => {
manager = createInMemoryManager();
const streamId = `oauth-delta-resume-${Date.now()}`;
await manager.createJob(streamId, 'user-1', streamId);
const runStepEvent = {
event: 'on_run_step',
data: {
id: 'step-oauth',
runId: 'USE_PRELIM_RESPONSE_MESSAGE_ID',
index: 0,
stepDetails: {
type: 'tool_calls',
tool_calls: [{ id: 'call-oauth', name: 'oauth_mcp_Google-Workspace', args: '' }],
},
},
} satisfies ServerSentEvent;
const authEvent = {
event: 'on_run_step_delta',
data: {
id: 'step-oauth',
delta: {
type: 'tool_calls',
tool_calls: [{ name: 'oauth_mcp_Google-Workspace', args: '' }],
auth: 'https://auth.example.com/oauth',
expires_at: 1780791946,
},
},
} satisfies ServerSentEvent;
await manager.emitChunk(streamId, runStepEvent);
await manager.emitChunk(streamId, authEvent);
await manager.emitChunk(streamId, {
event: 'on_run_step',
data: {
id: 'step-regular',
runId: 'response-1',
index: 1,
stepDetails: {
type: 'tool_calls',
tool_calls: [{ id: 'call-regular', name: 'regular-tool', args: '{}' }],
},
},
});
await manager.emitChunk(streamId, {
event: 'on_run_step_delta',
data: {
id: 'step-no-auth',
delta: {
type: 'tool_calls',
tool_calls: [{ name: 'regular-tool', args: '{}' }],
},
},
});
const resumeState = await manager.getResumeState(streamId);
expect(resumeState?.replayEvents).toEqual([runStepEvent, authEvent]);
});
test('replaces OAuth replay event for the same step id', async () => {
manager = createInMemoryManager();
const streamId = `oauth-delta-replace-${Date.now()}`;
await manager.createJob(streamId, 'user-1', streamId);
await manager.emitChunk(streamId, {
event: 'on_run_step_delta',
data: {
id: 'step-oauth',
delta: {
tool_calls: [{ name: 'oauth_mcp_Google-Workspace', args: '' }],
auth: 'https://auth.example.com/first',
expires_at: 1780791946,
},
},
});
const replacementEvent = {
event: 'on_run_step_delta',
data: {
id: 'step-oauth',
delta: {
tool_calls: [{ name: 'oauth_mcp_Google-Workspace', args: '' }],
auth: 'https://auth.example.com/latest',
expires_at: 1780792000,
},
},
} satisfies ServerSentEvent;
await manager.emitChunk(streamId, replacementEvent);
const resumeState = await manager.getResumeState(streamId);
expect(resumeState?.replayEvents).toEqual([replacementEvent]);
});
test('does not replay non-MCP action auth deltas', async () => {
manager = createInMemoryManager();
const streamId = `action-auth-delta-${Date.now()}`;
await manager.createJob(streamId, 'user-1', streamId);
await manager.emitChunk(streamId, {
event: 'on_run_step_delta',
data: {
id: 'step-action-auth',
delta: {
type: 'tool_calls',
tool_calls: [{ name: 'google_calendar_action_api_example_com', args: '' }],
auth: 'https://auth.example.com/action',
expires_at: 1780791946,
},
},
});
const resumeState = await manager.getResumeState(streamId);
expect(resumeState?.replayEvents).toBeUndefined();
});
test('serializes replay event updates for concurrent MCP OAuth prompts', async () => {
manager = createSnapshotReplayManager();
const streamId = `oauth-delta-concurrent-${Date.now()}`;
await manager.createJob(streamId, 'user-1', streamId);
const createRunStepEvent = (serverName: string, index: number) =>
({
event: 'on_run_step',
data: {
id: `step-oauth-${serverName}`,
runId: 'USE_PRELIM_RESPONSE_MESSAGE_ID',
index,
stepDetails: {
type: 'tool_calls',
tool_calls: [{ id: `call-${serverName}`, name: `oauth_mcp_${serverName}`, args: '' }],
},
},
}) satisfies ServerSentEvent;
const createAuthEvent = (serverName: string) =>
({
event: 'on_run_step_delta',
data: {
id: `step-oauth-${serverName}`,
delta: {
type: 'tool_calls',
tool_calls: [{ name: `oauth_mcp_${serverName}`, args: '' }],
auth: `https://auth.example.com/${serverName}`,
expires_at: 1780791946,
},
},
}) satisfies ServerSentEvent;
const events = [
createRunStepEvent('Google-Workspace', 0),
createAuthEvent('Google-Workspace'),
createRunStepEvent('clickhouse-docs', 1),
createAuthEvent('clickhouse-docs'),
];
await Promise.all(events.map((event) => manager!.emitChunk(streamId, event)));
const resumeState = await manager.getResumeState(streamId);
expect(resumeState?.replayEvents).toHaveLength(events.length);
expect(resumeState?.replayEvents).toEqual(expect.arrayContaining(events));
});
});

View file

@ -282,6 +282,182 @@ describe('AbortJob - Text and CollectedUsage', () => {
await GenerationJobManager.destroy();
});
it('should treat OAuth-only content as an early abort', async () => {
const { GenerationJobManager } = await import('../GenerationJobManager');
const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore');
const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport');
GenerationJobManager.configure({
jobStore: new InMemoryJobStore(),
eventTransport: new InMemoryEventTransport(),
isRedis: false,
cleanupOnComplete: false,
});
GenerationJobManager.initialize();
const streamId = `oauth-abort-${Date.now()}`;
await GenerationJobManager.createJob(streamId, 'user-1');
GenerationJobManager.setContentParts(streamId, [
{
type: 'tool_call',
tool_call: {
type: 'tool_call',
id: 'oauth-call-1',
name: 'oauth_mcp_Google-Workspace',
args: '',
auth: 'https://auth.example.com/oauth',
},
},
] as never);
const abortResult = await GenerationJobManager.abortJob(streamId);
expect(abortResult.success).toBe(true);
expect(abortResult.content).toEqual([]);
expect(abortResult.text).toBe('');
expect(abortResult.finalEvent).toEqual(
expect.objectContaining({
earlyAbort: true,
responseMessage: null,
}),
);
await GenerationJobManager.destroy();
});
it('should keep post-created OAuth-only aborts tied to the created turn', async () => {
const { GenerationJobManager } = await import('../GenerationJobManager');
const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore');
const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport');
GenerationJobManager.configure({
jobStore: new InMemoryJobStore(),
eventTransport: new InMemoryEventTransport(),
isRedis: false,
cleanupOnComplete: false,
});
GenerationJobManager.initialize();
const streamId = `created-oauth-abort-${Date.now()}`;
await GenerationJobManager.createJob(streamId, 'user-1');
await GenerationJobManager.updateMetadata(streamId, {
responseMessageId: 'response-message-1',
userMessage: {
messageId: 'user-message-1',
parentMessageId: 'parent-message-1',
conversationId: streamId,
text: 'Use Google Workspace',
},
});
await GenerationJobManager.emitChunk(streamId, {
created: true,
streamId,
message: {
messageId: 'user-message-1',
parentMessageId: 'parent-message-1',
conversationId: streamId,
text: 'Use Google Workspace',
sender: 'User',
isCreatedByUser: true,
},
});
GenerationJobManager.setContentParts(streamId, [
{
type: 'tool_call',
tool_call: {
type: 'tool_call',
id: 'oauth-call-1',
name: 'oauth_mcp_Google-Workspace',
args: '',
auth: 'https://auth.example.com/oauth',
},
},
] as never);
const abortResult = await GenerationJobManager.abortJob(streamId);
expect(abortResult.success).toBe(true);
expect(abortResult.content).toEqual([]);
expect(abortResult.text).toBe('');
expect(abortResult.finalEvent).toEqual(
expect.objectContaining({
earlyAbort: false,
conversation: { conversationId: streamId },
requestMessage: expect.objectContaining({
messageId: 'user-message-1',
}),
responseMessage: expect.objectContaining({
messageId: 'response-message-1',
content: [],
}),
}),
);
await GenerationJobManager.destroy();
});
it('should strip OAuth prompts but persist real abort content', async () => {
const { GenerationJobManager } = await import('../GenerationJobManager');
const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore');
const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport');
GenerationJobManager.configure({
jobStore: new InMemoryJobStore(),
eventTransport: new InMemoryEventTransport(),
isRedis: false,
cleanupOnComplete: false,
});
GenerationJobManager.initialize();
const streamId = `mixed-oauth-abort-${Date.now()}`;
await GenerationJobManager.createJob(streamId, 'user-1');
await GenerationJobManager.updateMetadata(streamId, {
responseMessageId: 'response-message-1',
userMessage: {
messageId: 'user-message-1',
parentMessageId: 'parent-message-1',
conversationId: streamId,
text: 'Use Google Workspace',
},
});
const textPart = { type: 'text', text: 'Partial response...' };
GenerationJobManager.setContentParts(streamId, [
{
type: 'tool_call',
tool_call: {
type: 'tool_call',
id: 'oauth-call-1',
name: 'oauth_mcp_Google-Workspace',
args: '',
auth: 'https://auth.example.com/oauth',
},
},
textPart,
] as never);
const abortResult = await GenerationJobManager.abortJob(streamId);
expect(abortResult.success).toBe(true);
expect(abortResult.content).toEqual([textPart]);
expect(abortResult.text).toBe('Partial response...');
expect(abortResult.finalEvent).toEqual(
expect.objectContaining({
earlyAbort: false,
responseMessage: expect.objectContaining({
content: [textPart],
}),
}),
);
await GenerationJobManager.destroy();
});
it('should return both text and collectedUsage on abort', async () => {
const { GenerationJobManager } = await import('../GenerationJobManager');
const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore');

View file

@ -0,0 +1,80 @@
import { Constants, ContentTypes } from 'librechat-data-provider';
import type { Agents, TMessageContentParts } from 'librechat-data-provider';
const OAUTH_TOOL_CALL_PREFIX = `oauth${Constants.mcp_delimiter}`;
type PersistableAbortContentPart = Agents.MessageContentComplex | TMessageContentParts;
type AbortContentPart = PersistableAbortContentPart | null | undefined;
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === 'object';
}
function getTextValue(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (isRecord(value) && typeof value.value === 'string') {
return value.value;
}
return '';
}
function getToolCall(part: Record<string, unknown>): Record<string, unknown> | null {
if (isRecord(part.tool_call)) {
return part.tool_call;
}
const keyedToolCall = part[ContentTypes.TOOL_CALL];
return isRecord(keyedToolCall) ? keyedToolCall : null;
}
function isOAuthPromptContentPart(part: AbortContentPart): boolean {
if (!isRecord(part) || part.type !== ContentTypes.TOOL_CALL) {
return false;
}
const partRecord = part as Record<string, unknown>;
const toolCall = getToolCall(partRecord);
if (!toolCall) {
return false;
}
const auth = typeof toolCall.auth === 'string' ? toolCall.auth : partRecord.auth;
if (typeof auth === 'string' && auth.trim().length > 0) {
return true;
}
const name = toolCall.name;
return typeof name === 'string' && name.startsWith(OAUTH_TOOL_CALL_PREFIX);
}
function isPersistableAbortContentPart(
part: AbortContentPart,
): part is PersistableAbortContentPart {
if (!isRecord(part)) {
return false;
}
if (isOAuthPromptContentPart(part)) {
return false;
}
if (part.type === ContentTypes.TEXT) {
return getTextValue(part.text).trim().length > 0;
}
if (part.type === ContentTypes.THINK) {
return getTextValue(part.think).trim().length > 0;
}
return Object.keys(part).length > 0;
}
export function hasPersistableAbortContent(content: unknown): boolean {
return filterPersistableAbortContent(content).length > 0;
}
export function filterPersistableAbortContent(content: unknown): PersistableAbortContentPart[] {
return Array.isArray(content) ? content.filter(isPersistableAbortContentPart) : [];
}

View file

@ -913,6 +913,7 @@ export class RedisJobStore implements IJobStore {
error: data.error || undefined,
userMessage: data.userMessage ? JSON.parse(data.userMessage) : undefined,
responseMessageId: data.responseMessageId || undefined,
createdEventEmitted: data.createdEventEmitted === '1',
sender: data.sender || undefined,
syncSent: data.syncSent === '1',
finalEvent: data.finalEvent || undefined,
@ -920,6 +921,8 @@ export class RedisJobStore implements IJobStore {
iconURL: data.iconURL || undefined,
model: data.model || undefined,
promptTokens: data.promptTokens ? parseInt(data.promptTokens, 10) : undefined,
titleEvent: data.titleEvent || undefined,
replayEvents: data.replayEvents || undefined,
};
}
}

View file

@ -15,6 +15,7 @@ export type {
export { createStreamServices } from './createStreamServices';
export type { StreamServicesConfig, StreamServices } from './createStreamServices';
export { filterPersistableAbortContent, hasPersistableAbortContent } from './abortContent';
// Implementations (for advanced use cases)
export { InMemoryJobStore } from './implementations/InMemoryJobStore';

View file

@ -1,5 +1,5 @@
import type { Agents } from 'librechat-data-provider';
import type { StandardGraph } from '@librechat/agents';
import type { Agents } from 'librechat-data-provider';
/**
* Job status enum
@ -30,6 +30,9 @@ export interface SerializableJobData {
/** Response message ID for reconnection */
responseMessageId?: string;
/** Whether the user-message created event has been emitted */
createdEventEmitted?: boolean;
/** Sender name for UI display */
sender?: string;
@ -42,6 +45,9 @@ export interface SerializableJobData {
/** Serialized title event for replay during active-stream resume */
titleEvent?: string;
/** Serialized replay-only stream events for active-stream resume */
replayEvents?: string;
/** Endpoint metadata for abort handling - avoids storing functions */
endpoint?: string;
iconURL?: string;
@ -149,6 +155,11 @@ export interface ResumeState {
title?: string;
};
};
replayEvents?: Array<{
event: string;
data?: unknown;
[key: string]: unknown;
}>;
}
/**

View file

@ -147,6 +147,8 @@ export type TSubmission = {
isContinued?: boolean;
isTemporary: boolean;
messages: TMessage[];
/** Client-only full message context used to restore branch siblings after scoped regenerate. */
regenerateMessages?: TMessage[];
isRegenerate?: boolean;
initialResponse?: TMessage;
conversation: Partial<TConversation>;

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-namespace */
import { StepTypes, ContentTypes, ToolCallTypes } from './runs';
import type { FunctionToolCall, SummaryContentPart } from './assistants';
import type { TAttachment, TPlugin } from 'src/schemas';
import { StepTypes, ContentTypes, ToolCallTypes } from './runs';
export namespace Agents {
export type MessageType = 'human' | 'ai' | 'generic' | 'system' | 'function' | 'tool' | 'remove';
@ -224,6 +224,11 @@ export namespace Agents {
title?: string;
};
};
replayEvents?: Array<{
event: string;
data?: unknown;
[key: string]: unknown;
}>;
}
/**
* Represents a run step delta i.e. any changed fields on a run step during