diff --git a/CONTEXT.md b/CONTEXT.md index 276b1f8ecab..3d27d23cae8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,7 @@ # Domain language - **Agent run envelope**: the versioned, JSON-safe request contract created after ingress authentication and protocol validation but before agent, provider, tool, or MCP initialization. It carries only the validated protocol payload and the minimum trusted principal identifiers. The execution host rehydrates all runtime state from those identifiers. +- **MCP runtime request body**: trusted chat identifiers supplied only while an MCP server handles an agent request. It enables request-scoped header placeholders without retaining user-specific request data on a shared server definition. - **Subagent thread**: a durable, view-only child conversation owned by one parent conversation and subagent identity. A parent agent may continue it by stable `threadId`; each continuation uses a fresh execution lease restored from the canonical child transcript. It is not an ordinary human-writable chat. - **Live subagent task owner**: the one API process holding a detached child execution, its abort controller, and its bounded control queue. Redis may route trusted poll/control envelopes to that owner, but it does not migrate or persist the executor; Mongo persists only the logical child thread and its continuation fence. - **Subagent completion wakeup**: a durable internal `continue` trigger pre-registered before detached child execution so a process crash cannot lose the wakeup. Delivery defers until the child's terminal transcript is persisted, targets the initiating agent and exact parent response branch, carries task metadata rather than child output, waits for the parent generation to settle, and starts the parent turn that collects the result through the existing task store. diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 00e3fc372f6..edfab4d2866 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -347,16 +347,22 @@ class BaseClient { const conversationId = requestConvoId ?? crypto.randomUUID(); const parentMessageId = opts.parentMessageId ?? Constants.NO_PARENT; const userMessageId = - overrideUserMessageId ?? opts.overrideParentMessageId ?? crypto.randomUUID(); - let responseMessageId = opts.responseMessageId ?? crypto.randomUUID(); + opts.preallocatedUserMessageId ?? + overrideUserMessageId ?? + opts.overrideParentMessageId ?? + crypto.randomUUID(); + let responseMessageId = + opts.responseMessageId ?? opts.preallocatedResponseMessageId ?? crypto.randomUUID(); let head = isEdited ? responseMessageId : parentMessageId; this.currentMessages = (await this.loadHistory(conversationId, head)) ?? []; this.conversationId = conversationId; if (isEdited && !isContinued) { - responseMessageId = crypto.randomUUID(); + responseMessageId = opts.preallocatedResponseMessageId ?? crypto.randomUUID(); head = responseMessageId; this.currentMessages[this.currentMessages.length - 1].messageId = head; + } else if (opts.preallocatedResponseMessageId != null) { + responseMessageId = opts.preallocatedResponseMessageId; } if (opts.isRegenerate && responseMessageId.endsWith('_')) { diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index b3b900e5c7f..6e78d3431fc 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -691,6 +691,21 @@ describe('BaseClient', () => { ); }); + it('honors response and user message IDs preallocated before initialization', async () => { + TestClient = initializeFakeClient(apiKey, options, messageHistory); + + const result = await TestClient.handleStartMethods('request-scoped MCP', { + conversationId, + parentMessageId: '3', + preallocatedUserMessageId: 'preallocated-user', + preallocatedResponseMessageId: 'preallocated-response', + }); + + expect(result.userMessage.messageId).toBe('preallocated-user'); + expect(result.responseMessageId).toBe('preallocated-response'); + expect(TestClient.responseMessageId).toBe('preallocated-response'); + }); + it('applies edited reasoning content from its typed payload before regeneration', async () => { const responseMessageId = 'response-with-reasoning'; const newHistory = [ diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 5a85c0d2242..e0f354e3672 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -592,7 +592,7 @@ const loadTools = async ({ user: safeUser, userMCPAuthMap, configServers, - requestBody: options.req?.body, + requestBody: options.requestBody ?? options.req?.body, requestScopedConnections, res: options.res, streamId: options.req?._resumableStreamId || null, diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index 389d58d8eb6..85c76d488bd 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -125,6 +125,13 @@ jest.mock('@librechat/api', () => ({ buildInitialToolSessions: jest.fn().mockReturnValue(mockInitialSessions), AgentRunEnvelopeError: MockAgentRunEnvelopeError, createAgentRunEnvelope: (...args) => mockCreateAgentRunEnvelope(...args), + createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId }) => ({ + messageId, + conversationId, + ...(parentMessageId !== undefined && { + parentMessageId: parentMessageId ?? '00000000-0000-0000-0000-000000000000', + }), + }), scopeSkillIds: jest.fn().mockImplementation((ids) => ids), resolveAgentScopedSkillIds: jest .fn() @@ -499,7 +506,10 @@ describe('OpenAIChatCompletionController', () => { const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0]; await toolExecuteOptions.loadTools(['file_search'], 'agent-123'); expect(loadToolsForExecution).toHaveBeenLastCalledWith( - expect.objectContaining({ agentResourceType: ResourceType.REMOTE_AGENT }), + expect.objectContaining({ + agentResourceType: ResourceType.REMOTE_AGENT, + requestBody: initializeParams.requestBody, + }), ); }); @@ -681,6 +691,79 @@ describe('OpenAIChatCompletionController', () => { }); describe('recursionLimit resolution', () => { + it('threads the OpenAI parent message id through both MCP execution bodies', async () => { + const { validateRequest, createRun, initializeAgent } = require('@librechat/api'); + const { getConvo } = require('~/models'); + validateRequest.mockReturnValueOnce({ + request: { + model: 'agent-123', + messages: [], + stream: false, + conversation_id: 'conversation-123', + parent_message_id: 'parent-123', + }, + }); + getConvo.mockResolvedValueOnce({ conversationId: 'conversation-123', user: 'user-123' }); + + await OpenAIChatCompletionController(req, res); + + expect(initializeAgent).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: { + messageId: 'chatcmpl-mock-nanoid-123', + conversationId: 'conversation-123', + parentMessageId: 'parent-123', + }, + }), + expect.anything(), + ); + expect(createRun).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: { + messageId: 'chatcmpl-mock-nanoid-123', + conversationId: 'conversation-123', + parentMessageId: 'parent-123', + }, + }), + ); + expect(mockProcessStream).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + configurable: expect.objectContaining({ + requestBody: { + messageId: 'chatcmpl-mock-nanoid-123', + conversationId: 'conversation-123', + parentMessageId: 'parent-123', + }, + }), + }), + expect.anything(), + ); + }); + + it('does not synthesize an MCP parent for a continuation that omits it', async () => { + const { validateRequest, initializeAgent } = require('@librechat/api'); + const { getConvo } = require('~/models'); + validateRequest.mockReturnValueOnce({ + request: { + model: 'agent-123', + messages: [], + stream: false, + conversation_id: 'conversation-123', + }, + }); + getConvo.mockResolvedValueOnce({ conversationId: 'conversation-123', user: 'user-123' }); + + await OpenAIChatCompletionController(req, res); + + const requestBody = initializeAgent.mock.calls.at(-1)[0].requestBody; + expect(requestBody).toEqual({ + messageId: 'chatcmpl-mock-nanoid-123', + conversationId: 'conversation-123', + }); + expect(requestBody).not.toHaveProperty('parentMessageId'); + }); + it('should pass resolveRecursionLimit result to processStream config', async () => { const { resolveRecursionLimit } = require('@librechat/api'); resolveRecursionLimit.mockReturnValueOnce(75); diff --git a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js index 7428c2287f1..f0bd8e2d03d 100644 --- a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js +++ b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js @@ -83,6 +83,11 @@ jest.mock('@librechat/api', () => ({ getAgentStartupTelemetry: jest.fn(() => undefined), acceptAgentStartupTelemetry: jest.fn(), isUnpersistedPreliminaryParent: jest.fn(async () => false), + createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId }) => ({ + messageId, + conversationId, + parentMessageId, + }), })); jest.mock('~/server/cleanup', () => ({ diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index cb56912c60f..3668c564d3e 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -195,6 +195,11 @@ jest.mock('@librechat/api', () => ({ return messages.length === 0; }, deleteAgentCheckpoint: (...args) => mockDeleteAgentCheckpoint(...args), + createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId }) => ({ + messageId, + conversationId, + parentMessageId, + }), })); jest.mock('~/server/cleanup', () => ({ @@ -383,6 +388,35 @@ describe('ResumableAgentController resume metadata', () => { }, ); + it.each(['overrideUserMessageId', 'overrideConvoId'])( + 'rejects a non-string %s before admission', + async (field) => { + const req = { + user: { id: 'user-123' }, + body: { + text: 'Invalid override identity', + messageId: 'user-message', + clientRequestId: 'override-request', + conversationId: 'conversation-123', + endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } }, + [field]: { malformed: true }, + }, + config: {}, + }; + const res = { json: jest.fn(), status: jest.fn(() => res) }; + + await AgentController(req, res, jest.fn(), jest.fn(), null); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ code: 'INVALID_OVERRIDE_ID' }), + ); + expect(mockGenerationJobManager.claimGeneration).not.toHaveBeenCalled(); + expect(mockGenerationJobManager.createJob).not.toHaveBeenCalled(); + expect(mockCheckAndIncrementPendingRequest).not.toHaveBeenCalled(); + }, + ); + it.each([ ['empty recovery id', { clientRequestId: 'steer-recovery:' }], ['regenerate', { isRegenerate: true }], @@ -695,9 +729,14 @@ describe('ResumableAgentController resume metadata', () => { preemptCapable: true, agent_id: undefined, isTemporary: true, - responseMessageId: 'follow-up-user_', + responseMessageId: expect.stringMatching(/^[0-9a-f-]{36}$/), + mcpRequestBody: { + messageId: expect.stringMatching(/^[0-9a-f-]{36}$/), + conversationId, + parentMessageId: expect.stringMatching(/^[0-9a-f-]{36}$/), + }, userMessage: { - messageId: 'follow-up-user', + messageId: expect.stringMatching(/^[0-9a-f-]{36}$/), parentMessageId: 'original-response', conversationId, text: 'Check Google Workspace availability.', @@ -1023,6 +1062,89 @@ describe('ResumableAgentController resume metadata', () => { ); }); + it('preallocates response-scoped MCP identities before native Agent initialization', async () => { + const initializeClient = jest.fn().mockRejectedValue(new Error('stop after MCP discovery')); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Use request-scoped headers.', + messageId: 'incoming-client-message', + parentMessageId: 'previous-response', + conversationId: 'conversation-123', + endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } }, + }, + config: {}, + }; + + await AgentController(req, createResumableResponse(), jest.fn(), initializeClient, null); + + expect(initializeClient).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: { + messageId: expect.stringMatching(/^[0-9a-f-]{36}$/), + conversationId: 'conversation-123', + parentMessageId: expect.stringMatching(/^[0-9a-f-]{36}$/), + }, + }), + ); + const [{ requestBody }] = initializeClient.mock.calls[0]; + const jobOptions = mockGenerationJobManager.createJob.mock.calls[0][3]; + expect(jobOptions.initialMetadata.responseMessageId).toBe(requestBody.messageId); + expect(jobOptions.initialMetadata.userMessage.messageId).toBe(requestBody.parentMessageId); + expect(jobOptions.initialMetadata.mcpRequestBody).toBe(requestBody); + expect(requestBody.messageId).not.toBe(req.body.messageId); + }); + + it('uses the effective overridden conversation in the MCP request body', async () => { + const initializeClient = jest.fn().mockRejectedValue(new Error('stop after MCP discovery')); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Continue in the overridden conversation.', + messageId: 'incoming-client-message', + parentMessageId: 'previous-response', + conversationId: 'source-conversation', + overrideConvoId: 'overridden-conversation__0', + endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } }, + }, + config: {}, + }; + + await AgentController(req, createResumableResponse(), jest.fn(), initializeClient, null); + + const [{ requestBody }] = initializeClient.mock.calls[0]; + const jobOptions = mockGenerationJobManager.createJob.mock.calls[0][3]; + expect(requestBody.conversationId).toBe('overridden-conversation'); + expect(jobOptions.initialMetadata.mcpRequestBody).toBe(requestBody); + }); + + it('preallocates the replacement response as the MCP parent for edited content', async () => { + const initializeClient = jest.fn().mockRejectedValue(new Error('stop after MCP discovery')); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Edited response text.', + messageId: 'existing-user-message', + responseMessageId: 'existing-response-message', + parentMessageId: 'previous-response', + overrideParentMessageId: 'existing-user-message', + editedContent: { index: 0, type: 'text', text: 'Edited response text.' }, + conversationId: 'conversation-123', + endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } }, + }, + config: {}, + }; + + await AgentController(req, createResumableResponse(), jest.fn(), initializeClient, null); + + const [{ requestBody }] = initializeClient.mock.calls[0]; + const jobOptions = mockGenerationJobManager.createJob.mock.calls[0][3]; + expect(requestBody.messageId).toMatch(/^[0-9a-f-]{36}$/); + expect(requestBody.parentMessageId).toBe(requestBody.messageId); + expect(requestBody.messageId).not.toBe('existing-response-message'); + expect(jobOptions.initialMetadata.mcpRequestBody).toBe(requestBody); + }); + it('stores model spec icon fallbacks and agent ids in early resume metadata', async () => { const conversationId = 'conversation-123'; const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading')); @@ -1080,6 +1202,48 @@ describe('ResumableAgentController resume metadata', () => { ); }); + it('records regeneration ownership for exact-ID resume reconstruction', async () => { + const conversationId = 'conversation-123'; + const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading')); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Regenerate the edited response.', + messageId: 'user-message', + parentMessageId: 'parent-message', + responseMessageId: 'edited-response', + isRegenerate: true, + conversationId, + endpointOption: { + endpoint: 'agents', + modelOptions: { model: 'gpt-4.1' }, + }, + }, + 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.createJob).toHaveBeenCalledWith( + conversationId, + 'user-123', + conversationId, + expect.objectContaining({ + initialMetadata: expect.objectContaining({ + responseMessageId: 'edited-response', + isRegenerate: true, + }), + }), + ); + }); + it('falls back to the model spec preset endpoint when no icon URL is configured', async () => { const conversationId = 'conversation-123'; const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading')); diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index f3500996d6f..b3059be876c 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -122,6 +122,13 @@ jest.mock('@librechat/api', () => ({ buildToolSet: jest.fn().mockReturnValue(new Set()), AgentRunEnvelopeError: MockAgentRunEnvelopeError, createAgentRunEnvelope: (...args) => mockCreateAgentRunEnvelope(...args), + createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId }) => ({ + messageId, + conversationId, + ...(parentMessageId !== undefined && { + parentMessageId: parentMessageId ?? '00000000-0000-0000-0000-000000000000', + }), + }), buildAgentScopedContext: (...args) => mockBuildAgentScopedContext(...args), buildInlineMemoryContext: (...args) => mockBuildInlineMemoryContext(...args), buildAgentContextAttachmentsByAgentId: (...args) => @@ -547,6 +554,15 @@ describe('createResponse controller', () => { expect(mockCreateAgentRunEnvelope.mock.invocationCallOrder[0]).toBeLessThan( initializeAgent.mock.invocationCallOrder[0], ); + expect(initializeAgent).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: { + messageId: 'resp_mock-123', + conversationId: expect.any(String), + }, + }), + expect.anything(), + ); expect(req.body).not.toBe(requestBody); expect(req.body).toEqual(requestBody); expect(JSON.stringify(mockCreateAgentRunEnvelope.mock.results[0].value)).not.toContain( @@ -734,7 +750,10 @@ describe('createResponse controller', () => { const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0]; await toolExecuteOptions.loadTools(['file_search'], 'agent-123'); expect(loadToolsForExecution).toHaveBeenLastCalledWith( - expect.objectContaining({ agentResourceType: ResourceType.REMOTE_AGENT }), + expect.objectContaining({ + agentResourceType: ResourceType.REMOTE_AGENT, + requestBody: initializeParams.requestBody, + }), ); }); }); diff --git a/api/server/controllers/agents/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js index 8fa406d7d6f..71c06a26346 100644 --- a/api/server/controllers/agents/__tests__/resume.spec.js +++ b/api/server/controllers/agents/__tests__/resume.spec.js @@ -103,6 +103,11 @@ jest.mock('@librechat/api', () => ({ decrementPendingRequest: (...args) => mockDecrementPendingRequest(...args), checkAndIncrementPendingRequest: (...args) => mockCheckAndIncrementPendingRequest(...args), isSteerPreemptSupported: jest.fn(() => true), + createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId }) => ({ + messageId, + conversationId, + parentMessageId, + }), })); jest.mock('~/models', () => ({ @@ -292,7 +297,7 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => { }); mockAddTitle = jest.fn().mockResolvedValue(undefined); - mockInitializeClient = jest.fn(async ({ req, checkpointNamespace }) => { + mockInitializeClient = jest.fn(async ({ req, checkpointNamespace, requestBody }) => { // Capture the request state the controller seeds BEFORE reconstruction. capturedInit = { parentMessageId: req.body.parentMessageId, @@ -301,6 +306,7 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => { conversationCreatedAt: req.conversationCreatedAt, timezone: req.body.timezone, checkpointNamespace, + requestBody, }; return { client: makeClient(), userMCPAuthMap: { server1: { token: 't' } } }; }); @@ -1195,6 +1201,11 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => { // initializeAgent scopes thread files off req.body.parentMessageId, seeded // from the paused user message's parent before initializeClient runs. expect(capturedInit.parentMessageId).toBe(THREAD_PARENT_ID); + expect(capturedInit.requestBody).toEqual({ + messageId: RESPONSE_MSG_ID, + conversationId: CONVO_ID, + parentMessageId: USER_MSG_ID, + }); expect(mockInitializeClient).toHaveBeenCalledTimes(1); const client = await mockInitializeClient.mock.results[0].value.then((r) => r.client); @@ -1206,6 +1217,23 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => { ); }); + it('reuses the persisted MCP identity for edited and overridden turns', async () => { + const persistedMCPRequestBody = { + messageId: RESPONSE_MSG_ID, + conversationId: 'overridden-conversation', + parentMessageId: RESPONSE_MSG_ID, + }; + mockGenerationJobManager.getJob.mockResolvedValue( + makeToolApprovalJob({ metadata: { mcpRequestBody: persistedMCPRequestBody } }), + ); + + await post(approveBody()); + await settled; + await flush(); + + expect(capturedInit.requestBody).toBe(persistedMCPRequestBody); + }); + it('reuses the persisted generation checkpoint namespace and keeps legacy fallback explicit', async () => { mockGenerationJobManager.getJob.mockResolvedValue( makeToolApprovalJob({ metadata: { checkpointNamespace: 'generation-1000' } }), diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index bb9201ab899..db5c9f5f614 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -60,6 +60,7 @@ const { createActivityLabelWiring, createActivityPhaseWiring, createReasoningLabelHostWiring, + createMCPRuntimeRequestBody, generateReasoningLabelRevision, getLabelUsageSequenceSeed, createAssistantPhaseStampingHandlers, @@ -2952,11 +2953,13 @@ class AgentClient extends BaseClient { last_agent_index: this.agentConfigs?.size ?? 0, user_id: this.user ?? this.options.req.user?.id, hide_sequential_outputs: this.options.agent.hide_sequential_outputs, - requestBody: { - messageId: this.responseMessageId, - conversationId: this.conversationId, - parentMessageId: this.parentMessageId, - }, + requestBody: + this.options.mcpRequestBody ?? + createMCPRuntimeRequestBody({ + messageId: this.responseMessageId, + conversationId: this.conversationId, + parentMessageId: this.parentMessageId, + }), user: createSafeUser(this.options.req.user), }, recursionLimit: resolveRecursionLimit(agentsEConfig, this.options.agent), @@ -3541,11 +3544,13 @@ class AgentClient extends BaseClient { last_agent_index: this.agentConfigs?.size ?? 0, user_id: this.user ?? this.options.req.user?.id, hide_sequential_outputs: this.options.agent.hide_sequential_outputs, - requestBody: { - messageId: this.responseMessageId, - conversationId: this.conversationId, - parentMessageId: this.parentMessageId, - }, + requestBody: + this.options.mcpRequestBody ?? + createMCPRuntimeRequestBody({ + messageId: this.responseMessageId, + conversationId: this.conversationId, + parentMessageId: this.parentMessageId, + }), user: createSafeUser(this.options.req.user), }, recursionLimit: resolveRecursionLimit(agentsEConfig, this.options.agent), diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index edb1a32c9fc..d3dbb19b71d 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -20,6 +20,7 @@ const { buildAgentContextAttachmentsByAgentId, AgentRunEnvelopeError, createAgentRunEnvelope, + createMCPRuntimeRequestBody, loadSkillStates, sendFinalChunk, createSafeUser, @@ -97,6 +98,7 @@ function createToolLoader(signal, definitionsOnly = true) { provider, tool_options, tool_resources, + requestBody, codeExecutionContext, accessibleMcpServerNames, }) { @@ -107,6 +109,7 @@ function createToolLoader(signal, definitionsOnly = true) { res, agent, signal, + requestBody, tool_resources, codeExecutionContext, agentResourceType: ResourceType.REMOTE_AGENT, @@ -255,6 +258,17 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { const conversationId = request.conversation_id ?? nanoid(); const parentMessageId = request.parent_message_id ?? null; + let mcpParentMessageId; + if (typeof request.parent_message_id === 'string' && request.parent_message_id.trim() !== '') { + mcpParentMessageId = request.parent_message_id; + } else if (request.conversation_id == null) { + mcpParentMessageId = null; + } + const mcpRequestBody = createMCPRuntimeRequestBody({ + messageId: responseId, + conversationId, + parentMessageId: mcpParentMessageId, + }); const agentsEConfig = appConfig?.endpoints?.[EModelEndpoint.agents]; const allowedProviders = new Set(agentsEConfig?.allowedProviders); @@ -347,6 +361,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { requestFiles: [], conversationId, parentMessageId, + requestBody: mcpRequestBody, agent, endpointOption, allowedProviders, @@ -414,6 +429,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { requestFiles: [], conversationId, parentMessageId, + requestBody: mcpRequestBody, resourceType: ResourceType.REMOTE_AGENT, computeAccessibleSkillIds: (handoffAgent) => resolveAgentScopedSkillIds({ @@ -553,6 +569,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { res, agentResourceType: ResourceType.REMOTE_AGENT, conversationId, + requestBody: mcpRequestBody, toolNames, agent: ctx.agent ?? agent, signal: abortController.signal, @@ -841,10 +858,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { appConfig, signal: abortController.signal, customHandlers: handlers, - requestBody: { - messageId: responseId, - conversationId, - }, + requestBody: mcpRequestBody, user: { id: userId }, tenantId: principal.tenantId, /** Bills subagent child-run model calls (reported outside the @@ -862,10 +876,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { thread_id: conversationId, user_id: userId, user: createSafeUser(req.user), - requestBody: { - messageId: responseId, - conversationId, - }, + requestBody: mcpRequestBody, ...(userMCPAuthMap != null && { userMCPAuthMap }), }, recursionLimit: resolveRecursionLimit(agentsEConfig, agent), diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index c6b9892e8fe..a28a568bd64 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -28,6 +28,7 @@ const { buildRecoveredSteerPayload, deleteAgentCheckpoint, getAttachmentTitleText, + createMCPRuntimeRequestBody, } = require('@librechat/api'); const { disposeClient } = require('~/server/cleanup'); const { @@ -96,18 +97,6 @@ async function attachConversationCreatedAt(req, conversationId, conversationAnch } } -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, quotes, files, manualSkills, alwaysAppliedSkills }, conversationId, @@ -382,6 +371,23 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit ); } const clientRequestId = rawClientRequestId; + const rawOverrideUserMessageId = req.body?.overrideUserMessageId; + const rawOverrideConversationId = req.body?.overrideConvoId; + if ( + (rawOverrideUserMessageId != null && typeof rawOverrideUserMessageId !== 'string') || + (rawOverrideConversationId != null && typeof rawOverrideConversationId !== 'string') + ) { + startupTelemetry?.end('rejected'); + return sendGenerationJson( + res, + 400, + { + code: 'INVALID_OVERRIDE_ID', + error: 'overrideUserMessageId and overrideConvoId must be strings.', + }, + generationProtocolVersion, + ); + } const rawExpectedPredecessorCreatedAt = req.body?.expectedPredecessorCreatedAt; if ( rawExpectedPredecessorCreatedAt != null && @@ -426,7 +432,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit } const recoveredSteerId = explicitRecoveredSteerId ?? legacyRecoveredSteerId; const isRecoveredSteerRequest = recoveredSteerId != null; - const recoveryUserMessageId = req.body?.overrideUserMessageId; + const recoveryUserMessageId = rawOverrideUserMessageId; const recoveredSteerPayload = isRecoveredSteerRequest ? buildRecoveredSteerPayload(text, req.body?.files) : undefined; @@ -985,6 +991,34 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit } startupTelemetry?.mark('request_admitted'); + /** Allocate the turn identities before Agent initialization. Request-scoped + * MCP transports resolve BODY placeholders while tools are discovered, so + * discovery and graph execution must receive the same response-scoped body. + * BaseClient otherwise allocates these IDs later in `sendMessage`, after MCP + * connections already exist. */ + const overrideUserMessageId = rawOverrideUserMessageId + ? rawOverrideUserMessageId.split(Constants.COMMON_DIVIDER)[0] + : undefined; + const preallocatedUserMessageId = + overrideUserMessageId ?? overrideParentMessageId ?? crypto.randomUUID(); + const overrideConversationId = rawOverrideConversationId + ? rawOverrideConversationId.split(Constants.COMMON_DIVIDER)[0] + : undefined; + const effectiveConversationId = overrideConversationId ?? conversationId; + let preallocatedResponseMessageId = editedResponseMessageId ?? crypto.randomUUID(); + if ( + (editedContent != null && !isContinued) || + (isRegenerate && preallocatedResponseMessageId.endsWith('_')) + ) { + preallocatedResponseMessageId = crypto.randomUUID(); + } + const mcpRequestBody = createMCPRuntimeRequestBody({ + messageId: preallocatedResponseMessageId, + conversationId: effectiveConversationId, + parentMessageId: + editedContent != null ? preallocatedResponseMessageId : preallocatedUserMessageId, + }); + let client = null; let jobCreatedAt; let providerExecutionId; @@ -1022,8 +1056,10 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit const endpointIconURL = getEndpointIconURL(req, endpointOption); const responseModel = getAgentResponseModel(req, endpointOption); - const preliminaryUserMessage = getPreliminaryUserMessage(req.body, conversationId); - const preliminaryResponseMessageId = getPreliminaryResponseMessageId(req.body); + const preliminaryUserMessage = getPreliminaryUserMessage( + { ...req.body, messageId: preallocatedUserMessageId }, + conversationId, + ); const job = await GenerationJobManager.createJob(streamId, userId, conversationId, { startupTelemetry, ...(recoveredSteerId && { recoveredSteerId }), @@ -1050,6 +1086,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit // Persist temporary-chat state so a HITL resume keeps the resumed response // non-persisted instead of trusting the resume request to re-send the flag. isTemporary: req.body?.isTemporary, + ...(isRegenerate && { isRegenerate: true }), ...(scheduleId ? { scheduleId, @@ -1061,7 +1098,8 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit ...(req._isManualScheduledFire === true && { scheduleManual: true }), } : {}), - responseMessageId: preliminaryResponseMessageId, + responseMessageId: preallocatedResponseMessageId, + mcpRequestBody, userMessage: preliminaryUserMessage, }, }); @@ -1245,6 +1283,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit signal: job.abortController.signal, jobCreatedAt, checkpointNamespace: job.metadata?.checkpointNamespace, + requestBody: mcpRequestBody, }); startupTelemetry?.mark('client_initialized'); client = result.client; @@ -1549,6 +1588,8 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit beforeResponsePersistence: claimBeforeResponsePersistence, userMCPAuthMap: result.userMCPAuthMap, responseMessageId: editedResponseMessageId, + preallocatedUserMessageId, + preallocatedResponseMessageId, progressOptions: { res: { write: () => true, diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 3fd5c45d3d9..021449c7ca1 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -16,6 +16,7 @@ const { buildToolSet, AgentRunEnvelopeError, createAgentRunEnvelope, + createMCPRuntimeRequestBody, buildAgentScopedContext, buildInlineMemoryContext, buildAgentContextAttachmentsByAgentId, @@ -107,6 +108,7 @@ function createToolLoader(signal, definitionsOnly = true) { provider, tool_options, tool_resources, + requestBody, codeExecutionContext, accessibleMcpServerNames, }) { @@ -117,6 +119,7 @@ function createToolLoader(signal, definitionsOnly = true) { res, agent, signal, + requestBody, tool_resources, codeExecutionContext, agentResourceType: ResourceType.REMOTE_AGENT, @@ -389,6 +392,7 @@ const executeResponse = async (envelope, { req, res }) => { const conversationId = request.previous_response_id ?? uuidv4(); const parentMessageId = null; + const mcpRequestBody = createMCPRuntimeRequestBody({ messageId: responseId, conversationId }); const agentsEConfig = appConfig?.endpoints?.[EModelEndpoint.agents]; // Build allowed providers set @@ -482,6 +486,7 @@ const executeResponse = async (envelope, { req, res }) => { requestFiles: [], conversationId, parentMessageId, + requestBody: mcpRequestBody, agent, endpointOption, allowedProviders, @@ -549,6 +554,7 @@ const executeResponse = async (envelope, { req, res }) => { requestFiles: [], conversationId, parentMessageId, + requestBody: mcpRequestBody, resourceType: ResourceType.REMOTE_AGENT, computeAccessibleSkillIds: (handoffAgent) => resolveAgentScopedSkillIds({ @@ -800,6 +806,7 @@ const executeResponse = async (envelope, { req, res }) => { res, agentResourceType: ResourceType.REMOTE_AGENT, conversationId, + requestBody: mcpRequestBody, toolNames, agent: ctx.agent ?? agent, signal: abortController.signal, @@ -867,10 +874,7 @@ const executeResponse = async (envelope, { req, res }) => { signal: abortController.signal, customHandlers: handlers, initialSessions, - requestBody: { - messageId: responseId, - conversationId, - }, + requestBody: mcpRequestBody, user: { id: userId }, tenantId: principal.tenantId, /** Bills subagent child-run model calls (reported outside the @@ -893,10 +897,7 @@ const executeResponse = async (envelope, { req, res }) => { thread_id: conversationId, user_id: userId, user: createSafeUser(req.user), - requestBody: { - messageId: responseId, - conversationId, - }, + requestBody: mcpRequestBody, ...(userMCPAuthMap != null && { userMCPAuthMap }), }, signal: abortController.signal, @@ -992,6 +993,7 @@ const executeResponse = async (envelope, { req, res }) => { res, agentResourceType: ResourceType.REMOTE_AGENT, conversationId, + requestBody: mcpRequestBody, toolNames, agent: ctx.agent ?? agent, signal: abortController.signal, @@ -1057,10 +1059,7 @@ const executeResponse = async (envelope, { req, res }) => { signal: abortController.signal, customHandlers: handlers, initialSessions, - requestBody: { - messageId: responseId, - conversationId, - }, + requestBody: mcpRequestBody, user: { id: userId }, tenantId: principal.tenantId, /** Bills subagent child-run model calls (reported outside the @@ -1082,10 +1081,7 @@ const executeResponse = async (envelope, { req, res }) => { thread_id: conversationId, user_id: userId, user: createSafeUser(req.user), - requestBody: { - messageId: responseId, - conversationId, - }, + requestBody: mcpRequestBody, ...(userMCPAuthMap != null && { userMCPAuthMap }), }, signal: abortController.signal, diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js index b2a4f450f11..5a8233be684 100644 --- a/api/server/controllers/agents/resume.js +++ b/api/server/controllers/agents/resume.js @@ -24,6 +24,7 @@ const { isSteerPreemptSupported, isStopConfirmed, toPendingSteer, + createMCPRuntimeRequestBody, } = require('@librechat/api'); const { disposeClient } = require('~/server/cleanup'); const { @@ -1190,6 +1191,13 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle) signal: job.abortController.signal, jobCreatedAt: job.createdAt, checkpointNamespace, + requestBody: + job.metadata.mcpRequestBody ?? + createMCPRuntimeRequestBody({ + messageId: job.metadata.responseMessageId, + conversationId: streamId, + parentMessageId: job.metadata.userMessage?.messageId ?? Constants.NO_PARENT, + }), }); client = result.client; diff --git a/api/server/controllers/assistants/v1.js b/api/server/controllers/assistants/v1.js index 926ab7db4dc..b27e5530b1d 100644 --- a/api/server/controllers/assistants/v1.js +++ b/api/server/controllers/assistants/v1.js @@ -7,7 +7,11 @@ const validateAuthor = require('~/server/middleware/assistants/validateAuthor'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { deleteAssistantActions } = require('~/server/services/ActionService'); const { getOpenAIClient, fetchAssistants } = require('./helpers'); -const { healMcpToolNames, getAssistantToolDefinitions } = require('~/server/services/MCP'); +const { + healMcpToolNames, + getAssistantToolDefinitions, + toProviderToolDefinition, +} = require('~/server/services/MCP'); const { manifestToolMap, isAgentsOnlyTool } = require('~/app/clients/tools'); /** @@ -30,8 +34,16 @@ const createAssistant = async (req, res) => { delete assistantData.conversation_starters; delete assistantData.append_current_datetime; - const toolDefinitions = await getAssistantToolDefinitions({ req, tools }); - const healedTools = await healMcpToolNames({ req, tools, toolDefinitions }); + const { toolDefinitions, accessibleServerNames } = await getAssistantToolDefinitions({ + req, + tools, + }); + const healedTools = await healMcpToolNames({ + req, + tools, + toolDefinitions, + accessibleServerNames, + }); assistantData.tools = healedTools .map((tool) => { @@ -59,7 +71,8 @@ const createAssistant = async (req, res) => { return toolDef; }) .filter((tool) => tool) - .flat(); + .flat() + .map(toProviderToolDefinition); let azureModelIdentifier = null; if (openai.locals?.azureOptions) { @@ -145,8 +158,16 @@ const patchAssistant = async (req, res) => { ...updateData } = req.body; - const toolDefinitions = await getAssistantToolDefinitions({ req, tools: updateData.tools }); - const healedTools = await healMcpToolNames({ req, tools: updateData.tools, toolDefinitions }); + const { toolDefinitions, accessibleServerNames } = await getAssistantToolDefinitions({ + req, + tools: updateData.tools, + }); + const healedTools = await healMcpToolNames({ + req, + tools: updateData.tools, + toolDefinitions, + accessibleServerNames, + }); updateData.tools = healedTools .map((tool) => { @@ -174,7 +195,8 @@ const patchAssistant = async (req, res) => { return toolDef; }) .filter((tool) => tool) - .flat(); + .flat() + .map(toProviderToolDefinition); if (openai.locals?.azureOptions && updateData.model) { updateData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName; diff --git a/api/server/controllers/assistants/v2.js b/api/server/controllers/assistants/v2.js index a436ed611db..ec0a3f9309d 100644 --- a/api/server/controllers/assistants/v2.js +++ b/api/server/controllers/assistants/v2.js @@ -2,7 +2,11 @@ const { logger } = require('@librechat/data-schemas'); const { ToolCallTypes } = require('librechat-data-provider'); const validateAuthor = require('~/server/middleware/assistants/validateAuthor'); const { validateAndUpdateTool } = require('~/server/services/ActionService'); -const { healMcpToolNames, getAssistantToolDefinitions } = require('~/server/services/MCP'); +const { + healMcpToolNames, + getAssistantToolDefinitions, + toProviderToolDefinition, +} = require('~/server/services/MCP'); const { manifestToolMap, isAgentsOnlyTool } = require('~/app/clients/tools'); const { updateAssistantDoc } = require('~/models'); const { getOpenAIClient } = require('./helpers'); @@ -28,8 +32,16 @@ const createAssistant = async (req, res) => { delete assistantData.conversation_starters; delete assistantData.append_current_datetime; - const toolDefinitions = await getAssistantToolDefinitions({ req, tools }); - const healedTools = await healMcpToolNames({ req, tools, toolDefinitions }); + const { toolDefinitions, accessibleServerNames } = await getAssistantToolDefinitions({ + req, + tools, + }); + const healedTools = await healMcpToolNames({ + req, + tools, + toolDefinitions, + accessibleServerNames, + }); assistantData.tools = healedTools .map((tool) => { @@ -57,7 +69,8 @@ const createAssistant = async (req, res) => { return toolDef; }) .filter((tool) => tool) - .flat(); + .flat() + .map(toProviderToolDefinition); let azureModelIdentifier = null; if (openai.locals?.azureOptions) { @@ -134,8 +147,16 @@ const updateAssistant = async ({ req, openai, assistant_id, updateData }) => { } let hasFileSearch = false; - const toolDefinitions = await getAssistantToolDefinitions({ req, tools: updateData.tools }); - const healedTools = await healMcpToolNames({ req, tools: updateData.tools, toolDefinitions }); + const { toolDefinitions, accessibleServerNames } = await getAssistantToolDefinitions({ + req, + tools: updateData.tools, + }); + const healedTools = await healMcpToolNames({ + req, + tools: updateData.tools, + toolDefinitions, + accessibleServerNames, + }); for (const tool of healedTools) { /** Agents-runtime-only tools (e.g. ask_user_question) cannot execute on * the assistants runtime — drop them even when posted directly, since @@ -201,7 +222,7 @@ const updateAssistant = async ({ req, openai, assistant_id, updateData }) => { }; } - updateData.tools = tools; + updateData.tools = tools.map(toProviderToolDefinition); if (openai.locals?.azureOptions && updateData.model) { updateData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName; diff --git a/api/server/controllers/mcp.js b/api/server/controllers/mcp.js index 1eb1b6eb3d0..19ea8d90b56 100644 --- a/api/server/controllers/mcp.js +++ b/api/server/controllers/mcp.js @@ -302,6 +302,10 @@ const getMCPTools = async (req, res) => { name: toolName, pluginKey: toolKey, description: toolData.function.description || '', + /** Upstream identity for keys that stripped a redundant + * server-name prefix — the agent editor migrates legacy + * persisted ids only when this proves the same tool. */ + ...(toolData.serverToolName != null && { serverToolName: toolData.serverToolName }), }); } } diff --git a/api/server/routes/__tests__/mcp.spec.js b/api/server/routes/__tests__/mcp.spec.js index 99ebff511a7..9fcb49a1aa4 100644 --- a/api/server/routes/__tests__/mcp.spec.js +++ b/api/server/routes/__tests__/mcp.spec.js @@ -2753,6 +2753,33 @@ describe('MCP Routes', () => { expect(getServerConnectionStatus).toHaveBeenCalledTimes(2); }); + it('preserves request-scoped metadata when an individual status check fails', async () => { + getMCPSetupData.mockResolvedValue({ + mcpConfig: { + server1: { + source: 'config', + headers: { 'X-Message': '{{LIBRECHAT_BODY_MESSAGEID}}' }, + customUserVars: { API_KEY: { title: 'API key' } }, + }, + }, + appConnections: new Map(), + userConnections: new Map(), + oauthServers: new Set(), + }); + getServerConnectionStatus.mockRejectedValueOnce(new Error('status unavailable')); + + const response = await request(app).get('/api/mcp/connection/status'); + + expect(response.status).toBe(200); + expect(response.body.connectionStatus.server1).toEqual( + expect.objectContaining({ + connectionState: 'error', + requestScoped: true, + configurationState: 'needs_configuration', + }), + ); + }); + it('should return 500 when connection status check fails', async () => { getMCPSetupData.mockRejectedValue(new Error('Database error')); diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js index bd82f8c5027..ceb987c9b4a 100644 --- a/api/server/routes/__tests__/share.spec.js +++ b/api/server/routes/__tests__/share.spec.js @@ -945,6 +945,28 @@ describe('share-scoped file routes', () => { expect(response.headers['content-disposition']).toContain('attachment'); }); + it('downloads stored text for a snapshotted text-source file', async () => { + getFiles.mockResolvedValue([{ status: 'ready', text: 'Shared extracted text' }]); + getSharedLinkFile.mockResolvedValue({ + file: { + file_id: 'file-1', + source: 'text', + filepath: 'mistral_ocr', + type: 'application/pdf', + filename: 'report.pdf', + }, + hasSnapshots: true, + }); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1/download'); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('text/plain'); + expect(response.headers['content-disposition']).toContain('attachment; report.pdf.txt'); + expect(response.text).toBe('Shared extracted text'); + expect(mockGetStrategyFunctions).not.toHaveBeenCalled(); + }); + it('returns 500 when the backing stream fails before sending bytes', async () => { const failingStream = new Readable({ read() { diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index 4f0caee52a4..612c970576e 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -570,6 +570,26 @@ router.get('/download/:userId/:file_id', fileAccess, async (req, res) => { // Access already validated by fileAccess middleware const file = req.fileAccess.file; + // Text-source files store extracted content in the DB; there is no backing file to stream + if (file.source === FileSources.text) { + /** `getFiles` excludes `text` by default, so the authorized record is re-fetched by `_id` */ + const [textFile] = (await db.getFiles({ _id: file._id }, null, { text: 1 })) ?? []; + if (textFile?.text == null) { + logger.warn(`File download requested by user ${userId} has no stored text: ${file_id}`); + return res.status(404).send('No file content found'); + } + const textFilename = file.filename?.toLowerCase().endsWith('.txt') + ? file.filename + : `${file.filename || file_id}.txt`; + res.setHeader('Content-Disposition', getContentDisposition(textFilename)); + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.setHeader( + 'X-File-Metadata', + encodeURIComponent(JSON.stringify(getDownloadFileMetadata(file))), + ); + return res.send(textFile.text); + } + if (checkOpenAIStorage(file.source) && !file.model) { logger.warn(`File download requested by user ${userId} has no associated model: ${file_id}`); return res.status(400).send('The model used when creating this file is not available'); @@ -642,6 +662,16 @@ router.get('/download/:userId/:file_id', fileAccess, async (req, res) => { fileStream.on('error', (streamError) => { logger.error('[DOWNLOAD ROUTE] Stream error:', streamError); + if (res.headersSent) { + if (!res.writableEnded) { + res.destroy(); + } + return; + } + res.removeHeader('Content-Disposition'); + res.removeHeader('Content-Type'); + res.removeHeader('X-File-Metadata'); + res.status(500).send('Error downloading file'); }); setHeaders(); diff --git a/api/server/routes/files/files.test.js b/api/server/routes/files/files.test.js index 4fcdd3a62a1..34894e2a566 100644 --- a/api/server/routes/files/files.test.js +++ b/api/server/routes/files/files.test.js @@ -940,6 +940,160 @@ describe('File Routes - Delete with Agent Access', () => { }), ); }); + + it('serves stored text for text-source files instead of streaming', async () => { + const userFileId = uuidv4(); + const getDownloadStream = jest.fn(); + getStrategyFunctions.mockReturnValue({ getDownloadStream }); + + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'screenshot.png', + filepath: FileSources.mistral_ocr, + bytes: 70, + type: 'text/plain', + source: FileSources.text, + text: 'Extracted OCR text', + }); + + const response = await request(app).get(`/files/download/${otherUserId}/${userFileId}`); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('text/plain'); + expect(response.headers['content-disposition']).toContain('screenshot.png.txt'); + expect(response.text).toBe('Extracted OCR text'); + const metadata = JSON.parse(decodeURIComponent(response.headers['x-file-metadata'])); + expect(metadata).toMatchObject({ file_id: userFileId, source: FileSources.text }); + expect(metadata).not.toHaveProperty('text'); + expect(getDownloadStream).not.toHaveBeenCalled(); + }); + + it('does not append .txt when the text-source filename already ends in .txt', async () => { + const userFileId = uuidv4(); + getStrategyFunctions.mockReturnValue({}); + + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'NOTES.TXT', + filepath: FileSources.mistral_ocr, + bytes: 20, + type: 'text/plain', + source: FileSources.text, + text: 'plain text notes', + }); + + const response = await request(app).get(`/files/download/${otherUserId}/${userFileId}`); + + expect(response.status).toBe(200); + expect(response.headers['content-disposition']).toContain('filename="NOTES.TXT"'); + expect(response.headers['content-disposition']).not.toContain('NOTES.TXT.txt'); + expect(response.text).toBe('plain text notes'); + }); + + it('returns 404 for text-source files without stored text', async () => { + const userFileId = uuidv4(); + const getDownloadStream = jest.fn(); + getStrategyFunctions.mockReturnValue({ getDownloadStream }); + + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'empty.png', + filepath: FileSources.mistral_ocr, + bytes: 0, + type: 'text/plain', + source: FileSources.text, + }); + + const response = await request(app).get(`/files/download/${otherUserId}/${userFileId}`); + + expect(response.status).toBe(404); + expect(response.text).toBe('No file content found'); + expect(getDownloadStream).not.toHaveBeenCalled(); + }); + + it('serves a valid empty stored-text result', async () => { + const userFileId = uuidv4(); + const getDownloadStream = jest.fn(); + getStrategyFunctions.mockReturnValue({ getDownloadStream }); + + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'empty.txt', + filepath: '/uploads/empty.txt', + bytes: 0, + type: 'text/plain', + source: FileSources.text, + text: '', + }); + + const response = await request(app).get(`/files/download/${otherUserId}/${userFileId}`); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('text/plain'); + expect(response.text).toBe(''); + expect(getDownloadStream).not.toHaveBeenCalled(); + }); + + it('responds with 500 when the download stream errors before data is sent', async () => { + const userFileId = uuidv4(); + const erroringStream = new Readable({ + read() { + this.destroy(new Error('ENOENT: no such file or directory')); + }, + }); + const getDownloadStream = jest.fn().mockResolvedValue(erroringStream); + getStrategyFunctions.mockReturnValue({ getDownloadStream }); + + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'gone.bin', + filepath: '/uploads/user/gone.bin', + bytes: 5, + type: 'application/octet-stream', + source: FileSources.local, + }); + + const response = await request(app).get(`/files/download/${otherUserId}/${userFileId}`); + + expect(response.status).toBe(500); + expect(response.text).toBe('Error downloading file'); + }); + + it('aborts the response when the download stream errors mid-transfer', async () => { + const userFileId = uuidv4(); + let pushed = false; + const erroringStream = new Readable({ + read() { + if (!pushed) { + pushed = true; + this.push('partial content'); + return; + } + this.destroy(new Error('read failed mid-stream')); + }, + }); + const getDownloadStream = jest.fn().mockResolvedValue(erroringStream); + getStrategyFunctions.mockReturnValue({ getDownloadStream }); + + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'truncated.bin', + filepath: '/uploads/user/truncated.bin', + bytes: 100, + type: 'application/octet-stream', + source: FileSources.local, + }); + + await expect( + request(app).get(`/files/download/${otherUserId}/${userFileId}`), + ).rejects.toThrow(/aborted|socket hang up|ECONNRESET/i); + }); }); describe('POST /files/usage', () => { diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index 554d1104052..32bca863d8e 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -23,6 +23,7 @@ const { OAUTH_SESSION_COOKIE, mcpConfig: mcpSettings, getServerCustomUserVars, + hasCustomUserVars, requiresEphemeralUserConnection, } = require('@librechat/api'); const { @@ -926,6 +927,9 @@ router.get('/connection/status', requireJwtAuth, async (req, res) => { { connectionState: 'error', requiresOAuth: oauthServers.has(serverName), + ...(requiresEphemeralUserConnection(config) && { requestScoped: true }), + ...(requiresEphemeralUserConnection(config) && + hasCustomUserVars(config) && { configurationState: 'needs_configuration' }), authorizationState: oauthServers.has(serverName) ? 'error' : 'not_required', error: message, }, @@ -987,6 +991,8 @@ router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) => serverName, connectionStatus: serverStatus.connectionState, requiresOAuth: serverStatus.requiresOAuth, + requestScoped: serverStatus.requestScoped, + configurationState: serverStatus.configurationState, authorizationState: serverStatus.authorizationState, }); } catch (error) { diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js index 5fcf51ba731..f248d79fc15 100644 --- a/api/server/routes/prompts.js +++ b/api/server/routes/prompts.js @@ -110,14 +110,13 @@ router.get('/all', async (req, res) => { category, }); - let accessibleIds = await findAccessibleResources({ - userId, - role: req.user.role, - resourceType: ResourceType.PROMPTGROUP, - requiredPermissions: PermissionBits.VIEW, - }); - - const [publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([ + const [accessibleIds, publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([ + findAccessibleResources({ + userId, + role: req.user.role, + resourceType: ResourceType.PROMPTGROUP, + requiredPermissions: PermissionBits.VIEW, + }), findPubliclyAccessibleResources({ resourceType: ResourceType.PROMPTGROUP, requiredPermissions: PermissionBits.VIEW, @@ -183,14 +182,13 @@ router.get('/groups', async (req, res) => { actualCursor = null; } - let accessibleIds = await findAccessibleResources({ - userId, - role: req.user.role, - resourceType: ResourceType.PROMPTGROUP, - requiredPermissions: PermissionBits.VIEW, - }); - - const [publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([ + const [accessibleIds, publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([ + findAccessibleResources({ + userId, + role: req.user.role, + resourceType: ResourceType.PROMPTGROUP, + requiredPermissions: PermissionBits.VIEW, + }), findPubliclyAccessibleResources({ resourceType: ResourceType.PROMPTGROUP, requiredPermissions: PermissionBits.VIEW, diff --git a/api/server/routes/share.js b/api/server/routes/share.js index c44ee8b7e64..c8068e11a17 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -197,7 +197,6 @@ const resolveShareFile = async (req, res, next) => { /** Stream (or redirect to) a snapshotted file from its original stored object. */ const streamSharedFile = async (req, res, file, requestedDisposition) => { const source = file.source || FileSources.local; - const { getDownloadStream, getDownloadURL } = getStrategyFunctions(source); // An update keeps the shareId, so these URLs are stable across re-publishes. Without // revalidation a viewer's cached copy would outlive a revoked "share files" choice or a @@ -209,6 +208,22 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => { return res.status(304).end(); } + if (source === FileSources.text) { + if (req.liveFile?.text == null) { + return res.status(404).send('No file content found'); + } + const textFilename = file.filename?.toLowerCase().endsWith('.txt') + ? file.filename + : `${file.filename || file.file_id}.txt`; + const disposition = requestedDisposition === 'inline' ? 'inline' : 'attachment'; + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Disposition', getContentDisposition(textFilename, disposition)); + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + return res.send(req.liveFile.text); + } + + const { getDownloadStream, getDownloadURL } = getStrategyFunctions(source); + // Inline only safe preview types; anything else is forced to attachment. const disposition = requestedDisposition === 'inline' && SAFE_INLINE_TYPES.has(file.type) ? 'inline' : 'attachment'; diff --git a/api/server/services/Config/__tests__/getCachedTools.lock.spec.js b/api/server/services/Config/__tests__/getCachedTools.lock.spec.js index 6302d20cadc..50e99ecfd0e 100644 --- a/api/server/services/Config/__tests__/getCachedTools.lock.spec.js +++ b/api/server/services/Config/__tests__/getCachedTools.lock.spec.js @@ -248,7 +248,7 @@ describe('global tool cache write lock', () => { expect.objectContaining({ keys: [ `${CacheKeys.TOOL_CACHE}:tools:metadata:mcp:user-generation:{user-1:server-1}`, - `${CacheKeys.TOOL_CACHE}:tools:mcp:user:{user-1:server-1}:config-current`, + `${CacheKeys.TOOL_CACHE}:tools:mcp:user:{user-1:server-1}:v2:config-current`, ], arguments: [ 'generation-current', @@ -342,7 +342,7 @@ describe('global tool cache write lock', () => { `tools:mcp:write-fence:{user-1:server-1}`, `${CacheKeys.TOOL_CACHE}:tools:metadata:mcp:user-legacy-fence:{user-1:server-1}`, `${CacheKeys.TOOL_CACHE}:tools:metadata:mcp:user-generation:{user-1:server-1}`, - `${CacheKeys.TOOL_CACHE}:tools:mcp:user:{user-1:server-1}:config-current`, + `${CacheKeys.TOOL_CACHE}:tools:mcp:user:{user-1:server-1}:v2:config-current`, ], }), ); diff --git a/api/server/services/Config/__tests__/getCachedTools.spec.js b/api/server/services/Config/__tests__/getCachedTools.spec.js index 6d3947392f1..dd5e231f023 100644 --- a/api/server/services/Config/__tests__/getCachedTools.spec.js +++ b/api/server/services/Config/__tests__/getCachedTools.spec.js @@ -29,10 +29,10 @@ describe('MCP tool cache', () => { it('uses collision-safe configuration-addressed keys', () => { expect(ToolCacheKeys.MCP_APP_SERVER('server:name', 'config/a')).toBe( - 'tools:mcp:app:server%3Aname:config%2Fa', + 'tools:mcp:app:v2:server%3Aname:config%2Fa', ); expect(ToolCacheKeys.MCP_SERVER('tenant:user', 'server:name', 'config/a')).toBe( - 'tools:mcp:user:{tenant%3Auser:server%3Aname}:config%2Fa', + 'tools:mcp:user:{tenant%3Auser:server%3Aname}:v2:config%2Fa', ); expect(ToolCacheKeys.MCP_SERVER('tenant:user', 'server:name', 'config/a')).not.toBe( ToolCacheKeys.MCP_SERVER('tenant', 'user:server:name', 'config/a'), @@ -49,7 +49,7 @@ describe('MCP tool cache', () => { }); it('keeps the legacy user key available for non-generation callers', () => { - expect(ToolCacheKeys.MCP_SERVER('user123', 'github')).toBe('tools:mcp:user123:github'); + expect(ToolCacheKeys.MCP_SERVER('user123', 'github')).toBe('tools:mcp:v2:user123:github'); }); it('gets and sets static global tools without touching MCP slices', async () => { diff --git a/api/server/services/Endpoints/agents/addedConvo.js b/api/server/services/Endpoints/agents/addedConvo.js index dee208f3fb8..9483b1535f0 100644 --- a/api/server/services/Endpoints/agents/addedConvo.js +++ b/api/server/services/Endpoints/agents/addedConvo.js @@ -42,6 +42,7 @@ const loadAddedAgent = (params) => * @param {Array} params.requestFiles - Request files * @param {string} params.conversationId - The conversation ID * @param {string} [params.parentMessageId] - The parent message ID for thread filtering + * @param {import('@librechat/api').MCPRuntimeRequestBody} [params.requestBody] * @param {Set} params.allowedProviders - Set of allowed providers * @param {Map} params.agentConfigs - Map of agent configs to add to * @param {string} params.primaryAgentId - The primary agent ID @@ -70,6 +71,7 @@ const processAddedConvo = async ({ requestFiles, conversationId, parentMessageId, + requestBody, allowedProviders, agentConfigs, primaryAgentId, @@ -170,6 +172,7 @@ const processAddedConvo = async ({ requestFiles, conversationId, parentMessageId, + requestBody, agent: addedAgent, endpointOption, allowedProviders, diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index ded114cdead..4e2dfd031ff 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -102,6 +102,7 @@ function createToolLoader(signal, streamId = null, definitionsOnly = false, jobC provider, tool_options, tool_resources, + requestBody, codeExecutionContext, accessibleMcpServerNames, }) { @@ -114,6 +115,7 @@ function createToolLoader(signal, streamId = null, definitionsOnly = false, jobC signal, streamId, jobCreatedAt, + requestBody, tool_resources, codeExecutionContext, definitionsOnly, @@ -137,6 +139,7 @@ function createToolLoader(signal, streamId = null, definitionsOnly = false, jobC * @param {Object} params.endpointOption * @param {number} [params.jobCreatedAt] * @param {string} [params.checkpointNamespace] Immutable saver-level generation scope + * @param {import('@librechat/api').MCPRuntimeRequestBody} [params.requestBody] */ const initializeClient = async ({ req, @@ -145,6 +148,7 @@ const initializeClient = async ({ endpointOption, jobCreatedAt, checkpointNamespace, + requestBody, }) => { if (!endpointOption) { throw new Error('Endpoint option not provided'); @@ -154,6 +158,7 @@ const initializeClient = async ({ * that trusted document for child-thread execution policy; resume and direct * callers fall back to the same owner-scoped lookup. */ const conversationId = req.body?.conversationId; + const runtimeRequestBody = requestBody ?? req.body; let requestConversationPromise = Promise.resolve(null); if (Object.prototype.hasOwnProperty.call(req, 'resolvedConversation')) { requestConversationPromise = Promise.resolve(req.resolvedConversation); @@ -334,6 +339,7 @@ const initializeClient = async ({ signal, streamId, conversationId, + requestBody: runtimeRequestBody, toolNames, agent: ctx.agent, toolRegistry: ctx.toolRegistry, @@ -496,6 +502,7 @@ const initializeClient = async ({ requestFiles, conversationId, parentMessageId, + requestBody: runtimeRequestBody, agent: primaryAgent, endpointOption, allowedProviders, @@ -561,6 +568,7 @@ const initializeClient = async ({ requestFiles, conversationId, parentMessageId, + requestBody: runtimeRequestBody, computeAccessibleSkillIds: (agent) => resolveAgentScopedSkillIds({ agent, @@ -651,6 +659,7 @@ const initializeClient = async ({ userMCPAuthMap, conversationId, parentMessageId, + requestBody: runtimeRequestBody, allowedProviders, primaryAgentId: primaryConfig.id, accessibleSkillIds, @@ -940,6 +949,7 @@ const initializeClient = async ({ requestFiles, conversationId, parentMessageId, + requestBody: runtimeRequestBody, endpointOption: { ...endpointOption, endpoint: EModelEndpoint.agents }, allowedProviders, accessibleSkillIds: scopedSkillIds, @@ -1417,6 +1427,7 @@ const initializeClient = async ({ toolInputValidationErrors, jobCreatedAt, checkpointNamespace, + mcpRequestBody: runtimeRequestBody, }); if (streamId) { diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 59910aa2a0c..927291b6b19 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -617,7 +617,7 @@ describe('initializeClient — subagent loading', () => { agentClientArgs = undefined; capturedToolExecuteOptions = undefined; mockLoadToolsForExecution.mockReset(); - mockLoadToolsForExecution.mockResolvedValue({ loadedTools: [] }); + mockLoadToolsForExecution.mockResolvedValue({ loadedTools: [], configurable: {} }); testUser = await User.create({ email: 'subagent@example.com', @@ -741,6 +741,32 @@ describe('initializeClient — subagent loading', () => { }); }); + it('uses one normalized MCP body for discovery, deferred execution, and AgentClient', async () => { + const requestBody = Object.freeze({ + messageId: 'response-message', + conversationId: 'conv_sub', + parentMessageId: 'user-message', + }); + mockInitializeAgent.mockResolvedValue(makePrimaryConfig({})); + mockLoadToolsForExecution.mockResolvedValue({ loadedTools: [], configurable: {} }); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + requestBody, + }); + + expect(mockInitializeAgent.mock.calls[0][0].requestBody).toBe(requestBody); + expect(agentClientArgs.mcpRequestBody).toBe(requestBody); + + await capturedToolExecuteOptions.loadTools([], PRIMARY_ID); + expect(mockLoadToolsForExecution).toHaveBeenCalledWith( + expect.objectContaining({ requestBody }), + ); + }); + it('keeps an existing detached task controllable after subagent config is disabled', async () => { mockInitializeAgent.mockResolvedValue( makePrimaryConfig({ diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 0c351996faf..359aa8979b9 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -10,9 +10,12 @@ const { splitMCPToolKey, normalizeServerName, normalizeMCPToolKey, + stripServerNamePrefix, + stripServerNamePrefixes, buildServerNameAliases, findShadowedServerNames, getAssistantToolDefinitions: loadAssistantToolDefinitions, + toProviderToolDefinition, resolveMCPServerContext, normalizeJsonSchema, GenerationJobManager, @@ -26,6 +29,7 @@ const { buildMCPAuthRunStepDeltaEvent, buildMCPAuthRunStepEndDeltaEvent, isUserSourced, + hasCustomUserVars, checkAccessWithRequestCache, getMissingCustomUserVars, getUserMCPAuthMap, @@ -216,8 +220,10 @@ async function resolveMcpServerContext(req) { */ /** * Names of every MCP server the user can reach (operator config + user DB), - * for the legacy-key heal's collision detection in `initializeAgent`. Only - * consulted when a configured server name needs normalization. + * for legacy-key healing: collision detection in `initializeAgent` (consulted + * when a configured server name needs normalization) and the assistants heal + * in `healMcpToolNames` (always, since assistants reference user-owned + * servers too). * @param {string} [userId] * @param {string} [role] * @returns {Promise} @@ -251,7 +257,7 @@ async function getAccessibleMcpServerNames(userId, role) { * @param {Record} params.toolDefinitions * @returns {Promise>} */ -async function healMcpToolNames({ req, tools, toolDefinitions }) { +async function healMcpToolNames({ req, tools, toolDefinitions, accessibleServerNames }) { const list = tools ?? []; const needsHeal = list.some( (tool) => @@ -262,21 +268,36 @@ async function healMcpToolNames({ req, tools, toolDefinitions }) { if (!needsHeal) { return list; } - const rawServerNames = await resolveMcpConfigNames(req); /** Cross-tier shadowing (DB `foo` vs operator `foo!`) is invisible to * operator names alone — the shadow set must come from the FULL - * accessible audit. Every rewrite candidate here is normalization- - * sensitive by construction, so an incomplete audit skips healing - * entirely (the raw key stays raw and fails closed). */ - const audit = await resolveCollisionAuditNames({ - rawServerNames, - userId: req.user?.id, - role: req.user?.role, - }); - if (!audit.complete) { - return list; + * accessible audit: assistants reference user-owned servers too (the + * definitions loader resolves them), so their pre-strip keys must heal + * against the same catalog. Callers holding the loader's snapshot pass + * it to avoid repeating the app-config and registry reads on the write + * path; without one, the audit is fetched here, and when it cannot + * complete healing is skipped entirely (the raw key stays raw and fails + * closed). */ + let auditNames = accessibleServerNames; + if (auditNames == null) { + const rawServerNames = await resolveMcpConfigNames(req); + try { + const accessible = await getAccessibleMcpServerNames(req.user?.id, req.user?.role); + auditNames = [...new Set([...accessible, ...rawServerNames])]; + } catch (error) { + logger.warn( + '[healMcpToolNames] Accessible-server audit unavailable; skipping legacy-key healing:', + error, + ); + return list; + } } - const shadowed = findShadowedServerNames(audit.names); + const shadowed = findShadowedServerNames(auditNames); + /** A pre-strip key persisted AFTER server-name normalization carries the + * NORMALIZED suffix, which the raw config names cannot match — the + * boundary must resolve against both spellings and map back to the raw + * name for the shadow and membership guards. */ + const serverNameAliases = buildServerNameAliases(auditNames); + const boundaryNames = [...new Set([...auditNames, ...serverNameAliases.keys()])]; const seen = new Set(); const healedList = []; for (const tool of list) { @@ -286,15 +307,47 @@ async function healMcpToolNames({ req, tools, toolDefinitions }) { tool.includes(Constants.mcp_delimiter) && toolDefinitions[tool] == null ) { - const [, parsedServerName] = splitMCPToolKey(tool, rawServerNames); - if ( - parsedServerName != null && - rawServerNames.includes(parsedServerName) && - !shadowed.has(parsedServerName) - ) { - const healed = normalizeMCPToolKey(tool, rawServerNames); + const [, parsedServerName] = splitMCPToolKey(tool, boundaryNames); + let rawServerName; + if (parsedServerName != null && auditNames.includes(parsedServerName)) { + rawServerName = parsedServerName; + } else if (parsedServerName != null) { + const aliased = serverNameAliases.get(parsedServerName); + /** A normalized spelling on a CONTESTED slot is ambiguous between the + * tie-break winner and its shadowed rivals — rewriting persisted + * data must fail closed here, mirroring the raw-spelling shadow + * guard, rather than bind the reference to the winner. */ + const contested = + aliased != null && + auditNames.some( + (name) => name !== aliased && normalizeServerName(name) === parsedServerName, + ); + rawServerName = contested ? undefined : aliased; + } + if (rawServerName != null && !shadowed.has(rawServerName)) { + const healed = normalizeMCPToolKey(tool, auditNames); if (toolDefinitions[healed] != null) { healedTool = healed; + } else { + /** Catalog keys built after redundant-prefix stripping no longer + * match a pre-strip persisted key — without this second candidate + * the exact-lookup below silently drops the tool from the + * assistant. The rewrite only lands when the stripped key actually + * exists in the loaded definitions, so an unstripped catalog + * (collision guard kept the raw name) never heals into a phantom. */ + const keyServerName = normalizeServerName(rawServerName); + const [healedToolName] = splitMCPToolKey(healed, [keyServerName]); + const strippedName = stripServerNamePrefix(healedToolName, keyServerName); + const strippedKey = `${strippedName}${Constants.mcp_delimiter}${keyServerName}`; + /** Rewrite only when the stripped entry PROVES the same upstream + * identity — a stale key for a removed tool must not be healed + * onto a different sibling that kept its raw name. */ + if ( + strippedName !== healedToolName && + toolDefinitions[strippedKey]?.serverToolName === healedToolName + ) { + healedTool = strippedKey; + } } } } @@ -807,6 +860,11 @@ async function createMCPTools({ } const serverTools = []; + const keyServerName = normalizeServerName(serverName); + const keyToolNames = stripServerNamePrefixes( + result.tools.map((tool) => tool.name), + keyServerName, + ); for (const tool of result.tools) { const toolInstance = await createMCPTool({ res, @@ -821,7 +879,7 @@ async function createMCPTools({ serverName, /** Model-facing key: matches the normalized `availableTools` keys and * the instance name `createToolInstance` will assign. */ - toolKey: `${tool.name}${Constants.mcp_delimiter}${normalizeServerName(serverName)}`, + toolKey: `${keyToolNames.get(tool.name) ?? tool.name}${Constants.mcp_delimiter}${keyServerName}`, requestBody, requestScopedConnections, config: serverConfig, @@ -936,18 +994,49 @@ async function createMCPTool({ /** Legacy keys persisted pre-normalization (assistants, direct tool * calls) carry the RAW server name, while `availableTools` is keyed by - * the canonical normalized key — look up both spellings. */ + * the canonical normalized key — look up both spellings. Keys are also + * built after redundant server-name-prefix stripping now, so a persisted + * pre-strip key (`acme_foo_mcp_acme`) must additionally try + * its stripped spelling or the tool degrades to an unavailable stub. */ + const keyServerName = serverName != null ? normalizeServerName(serverName) : undefined; const canonicalToolKey = - serverName != null - ? `${toolName}${Constants.mcp_delimiter}${normalizeServerName(serverName)}` - : toolKey; - const findToolDefinition = (tools) => - tools?.[toolKey]?.function ?? - (canonicalToolKey !== toolKey ? tools?.[canonicalToolKey]?.function : undefined); - - /** @type {LCTool | undefined} */ - let toolDefinition = findToolDefinition(availableTools); - if (!toolDefinition) { + keyServerName != null ? `${toolName}${Constants.mcp_delimiter}${keyServerName}` : toolKey; + const strippedToolName = + keyServerName != null ? stripServerNamePrefix(toolName, keyServerName) : toolName; + const strippedToolKey = + strippedToolName !== toolName + ? `${strippedToolName}${Constants.mcp_delimiter}${keyServerName}` + : null; + const candidateToolKeys = [toolKey]; + if (canonicalToolKey !== toolKey) { + candidateToolKeys.push(canonicalToolKey); + } + if (strippedToolKey != null && !candidateToolKeys.includes(strippedToolKey)) { + candidateToolKeys.push(strippedToolKey); + } + let matchedToolKey = toolKey; + const findToolEntry = (tools) => { + for (const key of candidateToolKeys) { + const entry = tools?.[key]; + if (!entry?.function) { + continue; + } + /** The stripped-spelling candidate is only a legacy match when the + * entry PROVES the same upstream identity — without this, a stale + * reference to a removed tool could strip onto a DIFFERENT sibling + * that kept its raw name and silently call the wrong tool. */ + if (key === strippedToolKey && entry.serverToolName !== toolName) { + continue; + } + matchedToolKey = key; + return entry; + } + return undefined; + }; + + /** @type {LCFunctionTool | undefined} */ + let toolEntry = findToolEntry(availableTools); + if (!toolEntry) { const cachedAt = useMissingToolCache ? missingToolCache.get(toolKey) : undefined; if (cachedAt && Date.now() - cachedAt < MISSING_TOOL_TTL_MS) { logger.debug( @@ -976,15 +1065,15 @@ async function createMCPTool({ if (result?.availableTools) { onAvailableTools?.(result.availableTools); } - toolDefinition = findToolDefinition(result?.availableTools); + toolEntry = findToolEntry(result?.availableTools); - if (!toolDefinition && useMissingToolCache) { + if (!toolEntry && useMissingToolCache) { missingToolCache.set(toolKey, Date.now()); evictStale(missingToolCache, MISSING_TOOL_TTL_MS); } } - if (!toolDefinition) { + if (!toolEntry) { logger.warn( `[MCP][${serverName}][${toolName}] Tool definition not found, returning unavailable stub.`, ); @@ -998,10 +1087,20 @@ async function createMCPTool({ requestBody, requestScopedConnections, provider, + /** A legacy pre-strip key that resolves to the stripped entry KEEPS its + * persisted spelling as the instance name: `agent.tools` entries and + * `tool_options` keys reference that spelling, and renaming the instance + * would silently detach those per-tool settings. The upstream call name + * still comes from the MATCHED entry — its recorded raw name, or the + * matched key's own tool half when the entry was never stripped. */ toolName, + serverToolName: + toolEntry.serverToolName ?? + (matchedToolKey === strippedToolKey ? strippedToolName : toolName), + currentToolName: matchedToolKey === strippedToolKey ? strippedToolName : undefined, serverName, serverConfig, - toolDefinition, + toolDefinition: toolEntry['function'], streamId, jobCreatedAt, }); @@ -1014,6 +1113,8 @@ function createToolInstance({ requestBody: capturedRequestBody, requestScopedConnections: capturedRequestScopedConnections, toolName, + serverToolName = toolName, + currentToolName, serverName, serverConfig: capturedServerConfig, toolDefinition, @@ -1091,7 +1192,9 @@ function createToolInstance({ const result = await mcpManager.callTool({ serverName, serverConfig: capturedServerConfig, - toolName, + /** The upstream server never sees stripped names — a key that dropped + * a redundant server-name prefix calls the ORIGINAL tool. */ + toolName: serverToolName, provider, toolArguments, options: { @@ -1170,6 +1273,17 @@ function createToolInstance({ }); toolInstance.mcp = true; toolInstance.mcpRawServerName = serverName; + if (serverToolName !== toolName) { + /** Upstream identity for stripped keys — lets the options aliasing in + * `buildToolClassification` heal legacy `tool_options` spellings. */ + toolInstance.mcpServerToolName = serverToolName; + } + if (currentToolName != null && currentToolName !== toolName) { + /** Current catalog spelling for a LEGACY-named instance, so approval + * policies and hook matchers written against the current name still + * reach it (see `collectMCPToolAliases`). */ + toolInstance.mcpCurrentToolName = currentToolName; + } // Ephemeral request-scoped servers (runtime body placeholders) tear their // connection down at request end, so they must never be backgrounded. A // missing/stale config means the server's lifetime is unknowable, so fail @@ -1348,6 +1462,19 @@ async function hasDurableMCPAuthorization(userId, serverName, config, runtimeCon }); } +async function getMCPUserConfigurationState(serverName, config, runtimeContext = {}) { + if (!hasCustomUserVars(config)) { + return undefined; + } + + const userMCPAuthMap = + runtimeContext.userMCPAuthMap ?? (await runtimeContext.loadUserMCPAuthMap?.()); + const customUserVars = getServerCustomUserVars(userMCPAuthMap, serverName); + return getMissingCustomUserVars(config, customUserVars).length > 0 + ? 'needs_configuration' + : 'configured'; +} + function canDetectMCPRuntimeOAuth(config) { return config.requiresOAuth == null && config.apiKey == null && hasRuntimeUrlPlaceholders(config); } @@ -1361,7 +1488,7 @@ function canDetectMCPRuntimeOAuth(config) { * @param {Map} userConnections - User-level connections * @param {Set} oauthServers - Set of OAuth servers * @param {{ user?: Partial, userMCPAuthMap?: Record>, loadUserMCPAuthMap?: () => Promise> | undefined>, loadMCPAllowlists?: () => Promise<{ allowedDomains?: string[] | null, allowedAddresses?: string[] | null }> }} [runtimeContext] - * @returns {Object} Object containing requiresOAuth and connectionState + * @returns {Object} Object containing requiresOAuth, requestScoped, connectionState, and authorizationState */ async function getServerConnectionStatus( userId, @@ -1378,6 +1505,10 @@ async function getServerConnectionStatus( const liveConnectionOAuth = connection?.usesOAuth?.() === true; const runtimeOAuthCandidate = canDetectMCPRuntimeOAuth(config); const effectiveOAuth = configuredOAuth || liveConnectionOAuth; + const requestScoped = requiresEphemeralUserConnection(config); + const configurationState = requestScoped + ? await getMCPUserConfigurationState(serverName, config, runtimeContext) + : undefined; const baseConnectionState = isStaleOrDoNotExist ? 'disconnected' @@ -1422,6 +1553,8 @@ async function getServerConnectionStatus( return { requiresOAuth, + ...(requestScoped && { requestScoped: true }), + ...(configurationState && { configurationState }), connectionState: finalConnectionState, authorizationState, }; @@ -1430,6 +1563,7 @@ async function getServerConnectionStatus( module.exports = { createMCPTool, createMCPTools, + toProviderToolDefinition, createMCPPermissionContext, userCanUseMCPServers, getMCPSetupData, diff --git a/api/server/services/MCP.spec.js b/api/server/services/MCP.spec.js index 976e6f81e82..36a1ac57edb 100644 --- a/api/server/services/MCP.spec.js +++ b/api/server/services/MCP.spec.js @@ -546,6 +546,60 @@ describe('tests for the new helper functions used by the MCP connection status e }); }); + it('marks BODY placeholder servers as request-scoped while they are idle', async () => { + const result = await getServerConnectionStatus( + mockUserId, + mockServerName, + { + ...mockConfig, + source: 'yaml', + headers: { 'X-Parent-Message': '{{LIBRECHAT_BODY_PARENTMESSAGEID}}' }, + }, + new Map(), + new Map(), + new Set(), + ); + + expect(result).toEqual({ + requiresOAuth: false, + requestScoped: true, + connectionState: 'disconnected', + authorizationState: 'not_required', + }); + }); + + it('reports whether custom variables are configured for request-scoped servers', async () => { + const config = { + ...mockConfig, + source: 'yaml', + headers: { 'X-Conversation': '{{LIBRECHAT_BODY_CONVERSATIONID}}' }, + customUserVars: { API_KEY: { title: 'API key' } }, + }; + const connectionArgs = [new Map(), new Map(), new Set()]; + + const missing = await getServerConnectionStatus( + mockUserId, + mockServerName, + config, + ...connectionArgs, + { userMCPAuthMap: {} }, + ); + const configured = await getServerConnectionStatus( + mockUserId, + mockServerName, + config, + ...connectionArgs, + { + userMCPAuthMap: { + [`${Constants.mcp_prefix}${mockServerName}`]: { API_KEY: 'secret' }, + }, + }, + ); + + expect(missing.configurationState).toBe('needs_configuration'); + expect(configured.configurationState).toBe('configured'); + }); + it('should prioritize app connection over user connection', async () => { const appConnections = new Map([ [ @@ -871,6 +925,7 @@ describe('tests for the new helper functions used by the MCP connection status e expect(result).toEqual({ requiresOAuth: true, + requestScoped: true, connectionState: 'connecting', authorizationState: 'authorizing', }); @@ -1830,6 +1885,146 @@ describe('User parameter passing tests', () => { expect(mockReinitMCPServer).not.toHaveBeenCalled(); }); + it('rejects a stripped-spelling entry without matching upstream identity', async () => { + /** A stale key for a removed tool must degrade to the unavailable stub, + * not resolve onto a DIFFERENT sibling whose key coincides with the + * stripped spelling. */ + const mockUser = { id: 'stale-identity-user', role: 'USER' }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + mockReinitMCPServer.mockResolvedValue(null); + + const staleKey = `acme_acme_foo${D}acme`; + const mcpTool = await createMCPTool({ + res: mockRes, + user: mockUser, + toolKey: staleKey, + provider: 'openai', + userMCPAuthMap: {}, + availableTools: { + [`acme_foo${D}acme`]: { + function: { + name: `acme_foo${D}acme`, + description: 'Different tool', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + }); + + expect(mockReinitMCPServer).toHaveBeenCalled(); + expect(mcpTool.description).toBe( + "This tool's MCP server is temporarily unavailable. Please try again shortly.", + ); + }); + + it('sends the raw upstream tool name when the key stripped a redundant server-name prefix', async () => { + const mockUser = { id: 'stripped-prefix-user', role: 'USER' }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + const { getRoleByName } = require('~/models'); + getRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + }, + }, + }); + const callTool = jest.fn().mockResolvedValue(['ok', null]); + mockGetMCPManager.mockReturnValue({ callTool }); + + const strippedKey = `trace_top_time_consuming_operations${D}acme`; + const mcpTool = await createMCPTool({ + res: mockRes, + user: mockUser, + toolKey: strippedKey, + provider: 'openai', + userMCPAuthMap: {}, + availableTools: { + [strippedKey]: { + serverToolName: 'acme_trace_top_time_consuming_operations', + function: { + name: strippedKey, + description: 'Trace', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + }); + + await mcpTool.invoke( + {}, + { + configurable: { user: mockUser }, + metadata: { provider: 'openai', thread_id: 'thread-1', run_id: 'run-1' }, + toolCall: {}, + }, + ); + + expect(mcpTool.name).toBe(strippedKey); + expect(callTool).toHaveBeenCalledWith( + expect.objectContaining({ + serverName: 'acme', + toolName: 'acme_trace_top_time_consuming_operations', + }), + ); + }); + + it('resolves a legacy pre-strip tool key to the stripped definition without reinit', async () => { + const mockUser = { id: 'legacy-prefix-user', role: 'USER' }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + const { getRoleByName } = require('~/models'); + getRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + }, + }, + }); + const callTool = jest.fn().mockResolvedValue(['ok', null]); + mockGetMCPManager.mockReturnValue({ callTool }); + + const strippedKey = `trace_top_time_consuming_operations${D}acme`; + const legacyKey = `acme_trace_top_time_consuming_operations${D}acme`; + const mcpTool = await createMCPTool({ + res: mockRes, + user: mockUser, + toolKey: legacyKey, + provider: 'openai', + userMCPAuthMap: {}, + availableTools: { + [strippedKey]: { + serverToolName: 'acme_trace_top_time_consuming_operations', + function: { + name: strippedKey, + description: 'Trace', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + }); + + expect(mockReinitMCPServer).not.toHaveBeenCalled(); + + await mcpTool.invoke( + {}, + { + configurable: { user: mockUser }, + metadata: { provider: 'openai', thread_id: 'thread-1', run_id: 'run-1' }, + toolCall: {}, + }, + ); + + /** The persisted spelling stays the instance name so `agent.tools` and + * `tool_options` keyed by it keep applying; only the upstream call + * uses the recorded raw name. */ + expect(mcpTool.name).toBe(legacyKey); + expect(callTool).toHaveBeenCalledWith( + expect.objectContaining({ + serverName: 'acme', + toolName: 'acme_trace_top_time_consuming_operations', + }), + ); + }); + it('should reject tool execution when user lacks MCP server use permission', async () => { const mockUser = { id: 'mcp-denied-user', role: 'USER' }; const mockRes = { write: jest.fn(), flush: jest.fn() }; diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index f03b6e8933b..efe9d535e69 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -259,6 +259,7 @@ async function processRequiredActions(client, requiredActions) { options: { processFileURL, req: client.req, + res: client.res, uploadImageBuffer, openAIApiKey: client.apiKey, returnMetadata: true, @@ -565,6 +566,7 @@ const isBuiltInTool = (toolName) => * @param {ServerRequest} params.req - The request object * @param {ServerResponse} [params.res] - The response object for SSE events * @param {Object} params.agent - The agent configuration + * @param {import('@librechat/api').RequestBody} [params.requestBody] - Normalized MCP body * @param {string} [params.agentResourceType] - Permission resource type for the authorized agent route * @param {string|null} [params.streamId] - Stream ID for resumable mode * @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted tool events @@ -580,6 +582,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, + requestBody, agentResourceType, streamId = null, jobCreatedAt, @@ -599,6 +602,7 @@ async function loadToolDefinitionsWrapper({ } const appConfig = req.config; + const runtimeRequestBody = requestBody ?? req.body; const hasExpectedMCPTools = agent.tools.some(isExpectedMCPTool); const enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent.id); @@ -620,7 +624,7 @@ async function loadToolDefinitionsWrapper({ environment: agent.stateful_code_environment, userId: req.user.id, agentId: agent.id, - conversationId: req.body?.conversationId, + conversationId: runtimeRequestBody?.conversationId, }); const hasMCPTools = agent.tools?.some((tool) => tool?.includes(Constants.mcp_delimiter)); const mcpPermissionContext = createMCPPermissionContext(req); @@ -927,7 +931,7 @@ async function loadToolDefinitionsWrapper({ serverName, configServers, userMCPAuthMap, - requestBody: req.body, + requestBody: runtimeRequestBody, requestScopedConnections, }); @@ -954,7 +958,7 @@ async function loadToolDefinitionsWrapper({ serverName, configServers, userMCPAuthMap, - requestBody: req.body, + requestBody: runtimeRequestBody, requestScopedConnections, }); @@ -1021,7 +1025,7 @@ async function loadToolDefinitionsWrapper({ return definitions; }; - let { toolDefinitions, toolRegistry, hasDeferredTools, mcpResolution } = + let { toolDefinitions, toolRegistry, hasDeferredTools, mcpToolAliases, mcpResolution } = await loadToolDefinitions( { userId: req.user.id, @@ -1082,7 +1086,7 @@ async function loadToolDefinitionsWrapper({ configServers, userMCPAuthMap, flowManager, - requestBody: req.body, + requestBody: runtimeRequestBody, returnOnOAuth: false, oauthStart, oauthEnd: createOAuthEndEmitter(serverName), @@ -1134,6 +1138,7 @@ async function loadToolDefinitionsWrapper({ toolDefinitions = reloadResult.toolDefinitions; toolRegistry = reloadResult.toolRegistry; hasDeferredTools = reloadResult.hasDeferredTools; + mcpToolAliases = reloadResult.mcpToolAliases; mcpResolution = reloadResult.mcpResolution; } } @@ -1242,6 +1247,7 @@ async function loadToolDefinitionsWrapper({ dynamicToolContextMap, toolDefinitions, hasDeferredTools, + mcpToolAliases, actionsEnabled, primedCodeFiles, }; @@ -1253,6 +1259,7 @@ async function loadToolDefinitionsWrapper({ * @param {ServerRequest} params.req - The request object * @param {ServerResponse} params.res - The response object * @param {Object} params.agent - The agent configuration + * @param {import('@librechat/api').RequestBody} [params.requestBody] - Normalized MCP body * @param {string} [params.agentResourceType] - Permission resource type for the authorized agent route * @param {AbortSignal} [params.signal] - Abort signal * @param {Object} [params.tool_resources] - Tool resources @@ -1267,6 +1274,7 @@ async function loadAgentTools({ req, res, agent, + requestBody, agentResourceType, signal, tool_resources, @@ -1283,6 +1291,7 @@ async function loadAgentTools({ req, res, agent, + requestBody, agentResourceType, streamId, jobCreatedAt, @@ -1400,7 +1409,7 @@ async function loadAgentTools({ environment: agent.stateful_code_environment, userId: req.user.id, agentId: agent.id, - conversationId: req.body?.conversationId, + conversationId: requestBody?.conversationId ?? req.body?.conversationId, }); const { loadedTools, toolContextMap, dynamicToolContextMap, primedCodeFiles } = await loadTools({ @@ -1413,6 +1422,7 @@ async function loadAgentTools({ options: { req, res, + requestBody, agentResourceType, mcpServerContext, jobCreatedAt, @@ -1434,7 +1444,7 @@ async function loadAgentTools({ /** Build tool registry from MCP tools and create PTC/tool search tools if configured */ const deferredToolsEnabled = checkCapability(AgentCapabilities.deferred_tools); const programmaticToolsEnabled = enabledCapabilities.has(AgentCapabilities.programmatic_tools); - const { toolRegistry, toolDefinitions, additionalTools, hasDeferredTools } = + const { toolRegistry, toolDefinitions, additionalTools, hasDeferredTools, mcpToolAliases } = await buildToolClassification({ loadedTools, userId: req.user.id, @@ -1504,6 +1514,7 @@ async function loadAgentTools({ dynamicToolContextMap, toolDefinitions, hasDeferredTools, + mcpToolAliases, actionsEnabled, tools: agentTools, primedCodeFiles, @@ -1523,6 +1534,7 @@ async function loadAgentTools({ dynamicToolContextMap, toolDefinitions, hasDeferredTools, + mcpToolAliases, actionsEnabled, tools: agentTools, primedCodeFiles, @@ -1653,6 +1665,7 @@ async function loadAgentTools({ userMCPAuthMap, toolDefinitions, hasDeferredTools, + mcpToolAliases, actionsEnabled, tools: agentTools, primedCodeFiles, @@ -1671,6 +1684,7 @@ async function loadAgentTools({ * @param {ServerResponse} params.res - The response object * @param {AbortSignal} [params.signal] - Abort signal * @param {Object} params.agent - The agent object + * @param {import('@librechat/api').RequestBody} [params.requestBody] - Normalized MCP body * @param {string} [params.agentResourceType] - Permission resource type for the authorized agent route * @param {string[]} params.toolNames - Names of tools to load * @param {Map} [params.toolRegistry] - Tool registry @@ -1690,6 +1704,7 @@ async function loadToolsForExecution({ res, signal, agent, + requestBody, agentResourceType, toolNames, toolRegistry, @@ -1707,8 +1722,13 @@ async function loadToolsForExecution({ }) { const appConfig = req.config; const allLoadedTools = []; + const runtimeRequestBody = requestBody ?? req.body; const mcpRequestScopedConnections = requestScopedConnections ?? getMCPRequestContext(req, res); - const configurable = { userMCPAuthMap, requestScopedConnections: mcpRequestScopedConnections }; + const configurable = { + userMCPAuthMap, + requestBody: runtimeRequestBody, + requestScopedConnections: mcpRequestScopedConnections, + }; /** Per-agent set of tools that received the injected `run_in_background` * param; the event-driven executor gates background dispatch and the * `check_background_task` poll tool on this reliable per-agent channel. */ @@ -1765,7 +1785,7 @@ async function loadToolsForExecution({ environment: agent?.stateful_code_environment, userId: req.user.id, agentId: agent?.id, - conversationId: conversationId ?? req.body?.conversationId, + conversationId: conversationId ?? runtimeRequestBody?.conversationId, }); configurable.codeExecutionContext = codeExecutionContext; @@ -1895,6 +1915,7 @@ async function loadToolsForExecution({ options: { req, res, + requestBody: runtimeRequestBody, agentResourceType, jobCreatedAt, tool_resources, diff --git a/api/server/services/__tests__/MCP.spec.js b/api/server/services/__tests__/MCP.spec.js index aa4ee0c0809..0fae7a3058d 100644 --- a/api/server/services/__tests__/MCP.spec.js +++ b/api/server/services/__tests__/MCP.spec.js @@ -115,8 +115,11 @@ describe('getAssistantToolDefinitions', () => { }); expect(definitions).toEqual({ - code_interpreter: { type: 'code_interpreter' }, - [toolKey]: mcpDefinition, + toolDefinitions: { + code_interpreter: { type: 'code_interpreter' }, + [toolKey]: mcpDefinition, + }, + accessibleServerNames: ['app-server'], }); expect(getMCPServerTools).toHaveBeenCalledWith('u1', 'app-server', serverConfig); }); @@ -135,7 +138,8 @@ describe('getAssistantToolDefinitions', () => { require('~/config').getMCPManager.mockReturnValue({ getServerToolFunctionsSnapshot }); await expect(getAssistantToolDefinitions({ req, tools: [toolKey] })).resolves.toEqual({ - [toolKey]: mcpDefinition, + toolDefinitions: { [toolKey]: mcpDefinition }, + accessibleServerNames: ['app-server'], }); expect(cacheMCPServerTools).toHaveBeenCalledWith({ userId: 'u1', @@ -159,7 +163,8 @@ describe('getAssistantToolDefinitions', () => { reinitMCPServer.mockResolvedValue({ availableTools: { [toolKey]: mcpDefinition } }); await expect(getAssistantToolDefinitions({ req, tools: [toolKey] })).resolves.toEqual({ - [toolKey]: mcpDefinition, + toolDefinitions: { [toolKey]: mcpDefinition }, + accessibleServerNames: ['app-server'], }); expect(reinitMCPServer).toHaveBeenCalledWith({ user: req.user, @@ -398,6 +403,140 @@ describe('healMcpToolNames', () => { expect(healed).toEqual([`search${Constants.mcp_delimiter}foo!`]); }); + it('heals a pre-strip prefixed key to the stripped catalog key', async () => { + /** Catalog keys drop a redundant leading server-name prefix now; an + * assistant saved before that resubmits the prefixed key and the exact + * lookup would silently drop the tool. */ + getAppConfig.mockResolvedValue({ mcpConfig: { acme: {} } }); + mockRegistry.ensureConfigServers.mockResolvedValue({}); + mockRegistry.getAllServerConfigs.mockResolvedValue({ acme: {} }); + const strippedKey = `search${Constants.mcp_delimiter}acme`; + const toolDefinitions = { + [strippedKey]: { type: 'function', serverToolName: 'acme_search' }, + }; + + const healed = await healMcpToolNames({ + req, + tools: [`acme_search${Constants.mcp_delimiter}acme`], + toolDefinitions, + }); + + expect(healed).toEqual([strippedKey]); + }); + + it('heals a pre-strip key whose server suffix is already normalized', async () => { + /** Keys persisted after server-name normalization carry the NORMALIZED + * suffix, which the raw config names cannot match — the strip heal must + * resolve the boundary against both spellings. */ + getAppConfig.mockResolvedValue({ mcpConfig: { 'My Server': {} } }); + mockRegistry.ensureConfigServers.mockResolvedValue({}); + mockRegistry.getAllServerConfigs.mockResolvedValue({ 'My Server': {} }); + const strippedKey = `search${Constants.mcp_delimiter}My_Server`; + const toolDefinitions = { + [strippedKey]: { type: 'function', serverToolName: 'my_server_search' }, + }; + + const healed = await healMcpToolNames({ + req, + tools: [`my_server_search${Constants.mcp_delimiter}My_Server`], + toolDefinitions, + }); + + expect(healed).toEqual([strippedKey]); + }); + + it('reuses a provided accessible-server snapshot without re-reading config', async () => { + /** The controllers pass the definitions loader's snapshot so the write + * path does not repeat the app-config and registry round trips. */ + const strippedKey = `search${Constants.mcp_delimiter}acme`; + const toolDefinitions = { + [strippedKey]: { type: 'function', serverToolName: 'acme_search' }, + }; + + const healed = await healMcpToolNames({ + req, + tools: [`acme_search${Constants.mcp_delimiter}acme`], + toolDefinitions, + accessibleServerNames: ['acme'], + }); + + expect(healed).toEqual([strippedKey]); + expect(getAppConfig).not.toHaveBeenCalled(); + expect(mockRegistry.getAllServerConfigs).not.toHaveBeenCalled(); + }); + + it('heals a pre-strip key for a USER-OWNED server absent from the operator config', async () => { + /** Assistants reference user DB servers too — the definitions loader + * resolves them, so the heal's audit must include them or the legacy + * key stays unhealed and the controllers drop the tool on edit. */ + getAppConfig.mockResolvedValue({ mcpConfig: {} }); + mockRegistry.ensureConfigServers.mockResolvedValue({}); + mockRegistry.getAllServerConfigs.mockResolvedValue({ acme: {} }); + const strippedKey = `search${Constants.mcp_delimiter}acme`; + const toolDefinitions = { + [strippedKey]: { type: 'function', serverToolName: 'acme_search' }, + }; + + const healed = await healMcpToolNames({ + req, + tools: [`acme_search${Constants.mcp_delimiter}acme`], + toolDefinitions, + }); + + expect(healed).toEqual([strippedKey]); + }); + + it('does not heal a stale key onto a sibling that lacks matching upstream identity', async () => { + /** With `acme_acme_foo` removed upstream while `acme_foo` kept its raw + * name, the stale key's stripped spelling exists but belongs to a + * DIFFERENT tool — the identity check must reject the rewrite. */ + getAppConfig.mockResolvedValue({ mcpConfig: { acme: {} } }); + mockRegistry.ensureConfigServers.mockResolvedValue({}); + mockRegistry.getAllServerConfigs.mockResolvedValue({ acme: {} }); + const staleKey = `acme_acme_foo${Constants.mcp_delimiter}acme`; + const toolDefinitions = { + [`acme_foo${Constants.mcp_delimiter}acme`]: { type: 'function' }, + [`foo${Constants.mcp_delimiter}acme`]: { type: 'function', serverToolName: 'acme_foo' }, + }; + + const healed = await healMcpToolNames({ req, tools: [staleKey], toolDefinitions }); + + expect(healed).toEqual([staleKey]); + }); + + it('fails closed on a normalized-suffix key whose slot is CONTESTED', async () => { + /** `My Server` and `My_Server!` both normalize to `My_Server`, so a + * normalized-suffix reference is ambiguous between them — rewriting + * persisted data must not bind it to the tie-break winner. */ + getAppConfig.mockResolvedValue({ mcpConfig: { 'My Server': {}, 'My_Server!': {} } }); + mockRegistry.ensureConfigServers.mockResolvedValue({}); + mockRegistry.getAllServerConfigs.mockResolvedValue({ 'My Server': {}, 'My_Server!': {} }); + const legacyKey = `my_server_search${Constants.mcp_delimiter}My_Server`; + const toolDefinitions = { [`search${Constants.mcp_delimiter}My_Server`]: { type: 'function' } }; + + const healed = await healMcpToolNames({ req, tools: [legacyKey], toolDefinitions }); + + expect(healed).toEqual([legacyKey]); + }); + + it('keeps a prefixed key whose stripped spelling is not in the loaded definitions', async () => { + /** When the catalog kept the raw name (bare-sibling collision), the + * prefixed key IS canonical and must not be rewritten into a key owned + * by the bare tool. */ + getAppConfig.mockResolvedValue({ mcpConfig: { acme: {} } }); + mockRegistry.ensureConfigServers.mockResolvedValue({}); + mockRegistry.getAllServerConfigs.mockResolvedValue({ acme: {} }); + const prefixedKey = `acme_search${Constants.mcp_delimiter}acme`; + const toolDefinitions = { + [prefixedKey]: { type: 'function' }, + [`search${Constants.mcp_delimiter}acme`]: { type: 'function' }, + }; + + const healed = await healMcpToolNames({ req, tools: [prefixedKey], toolDefinitions }); + + expect(healed).toEqual([prefixedKey]); + }); + it('skips the config read entirely when every delimiter-bearing name resolves', async () => { const key = `search${Constants.mcp_delimiter}srv`; const healed = await healMcpToolNames({ diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 7b2cb745422..04e35ca28c7 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -1429,6 +1429,33 @@ describe('ToolService - Action Capability Gating', () => { ); }); + it('threads the normalized MCP body through deferred tool loading', async () => { + const capabilities = [AgentCapabilities.tools, AgentCapabilities.web_search]; + const req = createMockReq(capabilities); + const requestBody = { + messageId: 'response-1', + conversationId: 'conversation-1', + parentMessageId: 'parent-1', + }; + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + + const result = await loadToolsForExecution({ + req, + res: {}, + requestBody, + agent: { id: 'agent_123', tools: [Tools.web_search] }, + toolNames: [Tools.web_search], + actionsEnabled: false, + }); + + expect(mockLoadToolsUtil).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ requestBody }), + }), + ); + expect(result.configurable.requestBody).toBe(requestBody); + }); + const actionToolName = `get_weather${actionDelimiter}api_example_com`; const regularTool = Tools.web_search; @@ -2147,6 +2174,11 @@ describe('ToolService - Action Capability Gating', () => { // zodSchema, name, and description for assistants API"), so key // resolution assertions off the request builder path instead. expect(mockCreateActionTool).toHaveBeenCalledTimes(2); + expect(mockLoadToolsUtil).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ res: client.res }), + }), + ); const builderPaths = mockCreateActionTool.mock.calls.map((c) => c[0].requestBuilder?.path); expect(builderPaths).toEqual(expect.arrayContaining(['/echo', '/items'])); // Each call must carry a distinct builder — guards against the bug diff --git a/client/src/Providers/AgentPanelContext.tsx b/client/src/Providers/AgentPanelContext.tsx index 493ef03a935..c561dd81b1a 100644 --- a/client/src/Providers/AgentPanelContext.tsx +++ b/client/src/Providers/AgentPanelContext.tsx @@ -1,20 +1,26 @@ -import React, { createContext, useContext, useState, useMemo } from 'react'; +import React, { createContext, useContext, useState, useMemo, useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; +import { useLocation } from 'react-router-dom'; import { EModelEndpoint } from 'librechat-data-provider'; import type { MCP, Action, TPlugin } from 'librechat-data-provider'; import type { AgentPanelContextType, MCPServerInfo } from '~/common'; +import { + useMCPConnectionStatus, + useMCPServerManager, + useGetAgentsConfig, + activateCatalog, + useCatalogReady, + useLocalize, +} from '~/hooks'; import { useAvailableToolsQuery, useGetActionsQuery, useGetStartupConfig, useMCPToolsQuery, } from '~/data-provider'; -import { - useLocalize, - useGetAgentsConfig, - useMCPConnectionStatus, - useMCPServerManager, -} from '~/hooks'; +import { isMCPServerReadyForAgent } from '~/components/MCP/mcpServerUtils'; import { Panel, isEphemeralAgent } from '~/common'; +import store from '~/store'; const AgentPanelContext = createContext(undefined); @@ -29,6 +35,18 @@ export function useAgentPanelContext() { /** Houses relevant state for the Agent Form Panels (formerly 'commonProps') */ export function AgentPanelProvider({ children }: { children: React.ReactNode }) { const localize = useLocalize(); + const location = useLocation(); + /** The panel stays mounted while the sidebar is hidden (collapsed, mobile + * drawer, or the insights route collapsing it), so only a visible form + * releases the MCP catalogs ahead of the background warmup schedule */ + const sidebarExpanded = useRecoilValue(store.sidebarExpanded); + const panelVisible = sidebarExpanded && !location.pathname.startsWith('/insights'); + useEffect(() => { + if (panelVisible) { + activateCatalog('mcpServers'); + activateCatalog('mcpTools'); + } + }, [panelVisible]); const [mcp, setMcp] = useState(undefined); const [mcps, setMcps] = useState(undefined); const [action, setAction] = useState(undefined); @@ -42,8 +60,12 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents); + /** The tools query keeps its own warmup gate: the servers list resolving + * alone must not pull the heavier tools request ahead of its stagger. */ + const mcpToolsReady = useCatalogReady('mcpTools'); const { data: mcpData, isFetching: mcpToolsFetching } = useMCPToolsQuery({ enabled: + mcpToolsReady && !isEphemeralAgent(agent_id) && !isLoading && availableMCPServers != null && @@ -71,6 +93,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) for (const [serverName, serverData] of Object.entries(mcpData.servers)) { // Get title and description from config with fallbacks const serverConfig = availableMCPServersMap?.[serverName]; + const serverStatus = connectionStatus?.[serverName]; const displayName = serverConfig?.title || serverName; const displayDescription = serverConfig?.description || `${localize('com_ui_tool_collection_prefix')} ${serverName}`; @@ -98,7 +121,12 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) serverName, tools, isConfigured: configuredServers.has(serverName), - isConnected: connectionStatus?.[serverName]?.connectionState === 'connected', + isConnected: serverStatus?.connectionState === 'connected', + isReadyForAgent: isMCPServerReadyForAgent( + serverStatus, + serverConfig?.requestScoped === true, + Object.keys(serverConfig?.customUserVars ?? {}).length > 0, + ), requestScoped: serverConfig?.requestScoped, metadata, consumeOnly: serverConfig?.consumeOnly, @@ -113,6 +141,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) } // Get title and description from config with fallbacks const serverConfig = availableMCPServersMap?.[mcpServerName]; + const serverStatus = connectionStatus?.[mcpServerName]; const displayName = serverConfig?.title || mcpServerName; const displayDescription = serverConfig?.description || @@ -130,7 +159,12 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) metadata, isConfigured: true, serverName: mcpServerName, - isConnected: connectionStatus?.[mcpServerName]?.connectionState === 'connected', + isConnected: serverStatus?.connectionState === 'connected', + isReadyForAgent: isMCPServerReadyForAgent( + serverStatus, + serverConfig?.requestScoped === true, + Object.keys(serverConfig?.customUserVars ?? {}).length > 0, + ), requestScoped: serverConfig?.requestScoped, consumeOnly: serverConfig?.consumeOnly, }); diff --git a/client/src/Providers/PromptGroupsContext.tsx b/client/src/Providers/PromptGroupsContext.tsx index 147d2bc3367..70751df8462 100644 --- a/client/src/Providers/PromptGroupsContext.tsx +++ b/client/src/Providers/PromptGroupsContext.tsx @@ -2,7 +2,7 @@ import React, { createContext, useContext, ReactNode, useMemo } from 'react'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; import type { TPromptGroup } from 'librechat-data-provider'; import type { PromptOption } from '~/common'; -import { usePromptGroupsNav, useHasAccess } from '~/hooks'; +import { usePromptGroupsNav, useHasAccess, useCatalogReady } from '~/hooks'; import { useGetAllPromptGroups } from '~/data-provider'; import { CategoryIcon } from '~/components/Prompts'; import { mapPromptGroups } from '~/utils'; @@ -31,10 +31,14 @@ export const PromptGroupsProvider = ({ children }: { children: ReactNode }) => { permissionType: PermissionTypes.PROMPTS, permission: Permissions.USE, }); + /** Prompt groups are a background-warmed catalog: the queries stay off the + * startup path until warmup releases them (or a prompts UI activates them). */ + const promptsReady = useCatalogReady('prompts'); + const promptsEnabled = hasAccess && promptsReady; - const promptGroupsNav = usePromptGroupsNav(hasAccess); + const promptGroupsNav = usePromptGroupsNav(promptsEnabled); const { data: allGroupsData, isLoading: isLoadingAll } = useGetAllPromptGroups(undefined, { - enabled: hasAccess, + enabled: promptsEnabled, select: (data) => { const mappedArray: PromptOption[] = data.map((group) => ({ id: group._id ?? '', diff --git a/client/src/common/types.ts b/client/src/common/types.ts index a86ca07d70f..28d2872217c 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -211,6 +211,8 @@ export interface MCPServerInfo { tools: t.AgentToolType[]; isConfigured: boolean; isConnected: boolean; + /** True when the server can be attached to an agent, even if its transport is request-scoped. */ + isReadyForAgent?: boolean; /** True when tools can only be discovered with live chat request fields. */ requestScoped?: boolean; consumeOnly?: boolean; diff --git a/client/src/components/Chat/Input/InFlightSteers.tsx b/client/src/components/Chat/Input/InFlightSteers.tsx index 31502e40174..633e6250a9a 100644 --- a/client/src/components/Chat/Input/InFlightSteers.tsx +++ b/client/src/components/Chat/Input/InFlightSteers.tsx @@ -552,6 +552,7 @@ const InFlightSteer = memo(function InFlightSteer({ fileId={selectedFile?.file_id} filePath={selectedFile?.filepath} fileType={selectedFile?.type ?? undefined} + fileSource={selectedFile?.source} fileSize={(selectedFile as TFile | null)?.bytes} /> )} diff --git a/client/src/components/Chat/Input/PromptsCommand.tsx b/client/src/components/Chat/Input/PromptsCommand.tsx index 6893c2e5b9d..7f08c343abf 100644 --- a/client/src/components/Chat/Input/PromptsCommand.tsx +++ b/client/src/components/Chat/Input/PromptsCommand.tsx @@ -9,6 +9,7 @@ import { removeCharIfLast, detectVariables } from '~/utils'; import { useRecordPromptUsage } from '~/data-provider'; import { VariableDialog } from '~/components/Prompts'; import { usePromptGroupsContext } from '~/Providers'; +import { activateCatalog } from '~/hooks'; import MentionItem from './MentionItem'; import { useLocalize } from '~/hooks'; import store from '~/store'; @@ -140,6 +141,8 @@ function PromptsCommand({ setActiveIndex(0); setSearchValue(''); } else { + /** Opening the picker before background warmup starts the fetch now */ + activateCatalog('prompts'); setVariableGroup(null); } }, [open, setSearchValue]); diff --git a/client/src/components/Chat/Input/__tests__/PromptsCommand.spec.tsx b/client/src/components/Chat/Input/__tests__/PromptsCommand.spec.tsx index adf9f637166..a15eba3760b 100644 --- a/client/src/components/Chat/Input/__tests__/PromptsCommand.spec.tsx +++ b/client/src/components/Chat/Input/__tests__/PromptsCommand.spec.tsx @@ -51,6 +51,7 @@ jest.mock('~/components/Prompts', () => ({ jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key, + activateCatalog: jest.fn(), })); /* react-virtualized renders nothing in jsdom without a measured size; replace diff --git a/client/src/components/Chat/Messages/Content/ApprovalContext.tsx b/client/src/components/Chat/Messages/Content/ApprovalContext.tsx index bb61c75a189..6c0e2ee8edf 100644 --- a/client/src/components/Chat/Messages/Content/ApprovalContext.tsx +++ b/client/src/components/Chat/Messages/Content/ApprovalContext.tsx @@ -303,7 +303,7 @@ export default function ApprovalProvider({ children }: { children: React.ReactNo * * Reads `ChatContext` / the agent store / React Query. The cards render it from * live chat views but ALSO from contexts without a `ChatContext.Provider` (e.g. a - * subagent tool paused inside a portaled dialog, or a search/citation render that + * subagent tool paused inside an isolated activity surface, or a search/citation render that * passes chat context as a prop), so it reads the context non-throwingly: with no * conversation, `buildResumeFields` returns null and the controls are inert rather * than crashing. diff --git a/client/src/components/Chat/Messages/Content/AskUserQuestionCall.tsx b/client/src/components/Chat/Messages/Content/AskUserQuestionCall.tsx index a3d622ab9ca..57675883eed 100644 --- a/client/src/components/Chat/Messages/Content/AskUserQuestionCall.tsx +++ b/client/src/components/Chat/Messages/Content/AskUserQuestionCall.tsx @@ -1,3 +1,5 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useRecoilValue } from 'recoil'; import { MessageCircleQuestion, TriangleAlert } from 'lucide-react'; import type { Agents } from 'librechat-data-provider'; import { @@ -6,9 +8,11 @@ import { parseAskUserQuestionsArgs, } from '~/utils/approval'; import AskUserQuestionProgress from './AskUserQuestionProgress'; +import { useLocalize, useExpandCollapse } from '~/hooks'; +import ProgressText from './ProgressText'; import EmptyText from './Parts/EmptyText'; -import { useLocalize } from '~/hooks'; import Container from './Container'; +import store from '~/store'; /** * Static rendering of a COMPLETED (or abandoned) `ask_user_question` tool call — @@ -16,6 +20,12 @@ import Container from './Container'; * is wrong here: it labels a no-output call "cancelled" and shows raw JSON args. * The interactive card ({@link AskUserQuestion}) renders only while the pause is * live; this component owns the part everywhere else (history, reload, exports). + * + * Settled, it is history — so it reads as one collapsed tool-call line (status + * label plus the question itself) and opens on demand, under the same + * `autoExpandTools` preference every other tool card follows. Answers are + * frequently long, multi-paragraph text; left expanded they buried the reply + * that followed them. */ export default function AskUserQuestionCall({ args, @@ -24,6 +34,7 @@ export default function AskUserQuestionCall({ isSubmitting = false, failed = false, showCursor = false, + onExpand, }: { args: string | Record | undefined; output: string; @@ -31,8 +42,29 @@ export default function AskUserQuestionCall({ isSubmitting?: boolean; failed?: boolean; showCursor?: boolean; + onExpand?: () => void; }) { const localize = useLocalize(); + const autoExpand = useRecoilValue(store.autoExpandTools); + const [expanded, setExpanded] = useState(autoExpand); + const { style: expandStyle, ref: expandRef } = useExpandCollapse(expanded); + + useEffect(() => { + if (autoExpand) { + setExpanded(true); + } + }, [autoExpand]); + + const toggleExpanded = useCallback(() => { + setExpanded((prev) => { + const next = !prev; + if (next) { + onExpand?.(); + } + return next; + }); + }, [onExpand]); + const question = parseAskUserQuestionArgs(args); const batch = parseAskUserQuestionsArgs(args); /** @@ -88,137 +120,169 @@ export default function AskUserQuestionCall({ ) : null; - if (batch != null) { - let statusLabel = localize('com_ui_asking'); + const count = batch?.questions.length ?? 1; + /** + * Past tense unconditionally: a live, unanswered pause returns above, so + * every state that reaches this header is settled — answered, abandoned + * (the run stopped before an answer), or rejected. An abandoned pause was + * still ASKED; it explains itself with "no answer" inside the panel, and a + * present-tense summary would strand it as permanently in-flight now that + * the panel starts closed. Matches `ToolCallGroup`, which settles its own + * question header on `!isSubmitting`. + */ + const statusLabel = (() => { if (failed) { - statusLabel = localize('com_ui_question_failed'); - } else if (answered) { - statusLabel = localize('com_ui_asked'); + return localize('com_ui_question_failed'); } - return ( - <> -
-
- {failed ? ( -
+ ); } diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx index eac90450d2b..8b03aa24436 100644 --- a/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx @@ -8,6 +8,7 @@ const mockSetValue = jest.fn(); const mockGetValues = jest.fn((): string[] => []); const mockGetToolOptions = jest.fn((): Record | undefined => undefined); const mockMcpServersMap = jest.fn((): Map => new Map()); +const mockGetServerStatusIconProps = jest.fn((): object | null => null); const mockInitializeServer = jest.fn(); const mockIsConnectionDeferred = jest.fn((): boolean => false); const mockToggleIntentAll = jest.fn(); @@ -20,6 +21,9 @@ const mockCapabilities = { backgroundToolsEnabled: false, toolIntentsEnabled: false, }; +const mockLocalize = jest.fn((key: string, values?: Record) => + key === 'com_nav_mcp_status_connecting' ? `${values?.[0]} - Connecting` : key, +); jest.mock('react-hook-form', () => ({ useFormContext: () => ({ control: {}, setValue: mockSetValue, getValues: mockGetValues }), @@ -46,12 +50,12 @@ jest.mock('~/components/ui', () => ({ })); jest.mock('~/hooks', () => ({ - useLocalize: () => (key: string) => key, + useLocalize: () => mockLocalize, useCopyToClipboard: () => jest.fn(), useAgentCapabilities: () => mockCapabilities, useGetAgentsConfig: () => ({ agentsConfig: { capabilities: [] } }), useMCPServerManager: () => ({ - getServerStatusIconProps: () => null, + getServerStatusIconProps: mockGetServerStatusIconProps, getConfigDialogProps: () => null, initializeServer: mockInitializeServer, isConnectionDeferred: mockIsConnectionDeferred, @@ -121,6 +125,7 @@ jest.mock('@librechat/client', () => { const React = jest.requireActual('react'); return { TooltipAnchor: ({ render }: { render: React.ReactElement }) => render, + Spinner: ({ className }: { className?: string }) => React.createElement('span', { className }), Button: ({ children, variant: _variant, @@ -181,6 +186,9 @@ describe('McpSection', () => { mockGetToolOptions.mockReturnValue(undefined); mockMcpServersMap.mockReset(); mockMcpServersMap.mockReturnValue(new Map()); + mockGetServerStatusIconProps.mockReset(); + mockGetServerStatusIconProps.mockReturnValue(null); + mockLocalize.mockClear(); mockCodeInterpreterSelected.mockReset(); mockCodeInterpreterSelected.mockReturnValue(false); mockCapabilities.codeEnabled = false; @@ -196,6 +204,21 @@ describe('McpSection', () => { expect(screen.getByTestId('tool-mcp:srv:b')).toBeInTheDocument(); }); + test('interpolates the server name when another manager reports a connecting state', () => { + mockGetServerStatusIconProps.mockReturnValue({ + serverStatus: { + connectionState: 'connecting', + requiresOAuth: true, + }, + isInitializing: false, + }); + + render(); + + expect(screen.getByText('srv - Connecting')).toBeInTheDocument(); + expect(mockLocalize).toHaveBeenCalledWith('com_nav_mcp_status_connecting', { 0: 'srv' }); + }); + test('toggling a tool writes its id plus the server token into agent.tools', () => { render(); fireEvent.click(screen.getByTestId('tool-mcp:srv:a')); @@ -282,13 +305,14 @@ describe('McpSection', () => { expect(screen.getByText('com_ui_tools_mcp_no_tools')).toBeInTheDocument(); }); - test('lets an already-connected request-scoped server attach its runtime tools', () => { + test('lets a ready request-scoped server attach its runtime tools', () => { const runtimeItem: McpItem = { ...item, server: { ...item.server, tools: [], - isConnected: true, + isConnected: false, + isReadyForAgent: true, requestScoped: true, } as never, toolCount: 0, @@ -320,6 +344,7 @@ describe('McpSection', () => { ...item.server, tools: [], isConnected: true, + isReadyForAgent: true, requestScoped: true, } as never, toolCount: 0, diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx index d03ed996303..accc7941b30 100644 --- a/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx @@ -8,7 +8,9 @@ import { splitMCPToolKey, normalizeServerName, buildServerNameAliases, + stripServerNamePrefix, } from 'librechat-data-provider'; +import type { MCPServerStatus } from 'librechat-data-provider'; import type { MouseEvent } from 'react'; import type { TranslationKeys } from '~/hooks/useLocalize'; import type { McpItem } from '../../items/types'; @@ -20,6 +22,7 @@ import { useMCPToolOptions, } from '~/hooks'; import { matchesMcpServer, mcpAllToken, mcpServerToken } from '../../items/selectors'; +import { getStatusColor, getStatusTextKey } from '~/components/MCP/mcpServerUtils'; import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; import McpOAuthDialog from '~/components/MCP/McpOAuthDialog'; @@ -37,29 +40,23 @@ interface StatusDisplay { } function getStatusDisplay( - connectionState: string | undefined, + serverName: string, + serverStatus: MCPServerStatus | undefined, isInitializing: boolean, isConfigured: boolean, ): StatusDisplay { - if (isInitializing || connectionState === 'connecting') { - return { - labelKey: 'com_nav_mcp_status_initializing', - dotClass: 'bg-blue-500 animate-pulse', - }; + if (!serverStatus && !isInitializing && !isConfigured) { + return { labelKey: 'com_ui_tools_mcp_status_unconfigured', dotClass: 'bg-status-neutral' }; } - if (connectionState === 'connected') { - return { labelKey: 'com_nav_mcp_status_connected', dotClass: 'bg-emerald-500' }; - } - if (connectionState === 'error') { - return { labelKey: 'com_nav_mcp_status_error', dotClass: 'bg-red-500' }; - } - if (connectionState === 'disconnected') { - return { labelKey: 'com_nav_mcp_status_disconnected', dotClass: 'bg-amber-500' }; - } - if (!isConfigured) { - return { labelKey: 'com_ui_tools_mcp_status_unconfigured', dotClass: 'bg-gray-400' }; - } - return { labelKey: 'com_nav_mcp_status_unknown', dotClass: 'bg-gray-400' }; + const connectionStatus = serverStatus ? { [serverName]: serverStatus } : undefined; + const initializing = () => isInitializing; + return { + labelKey: getStatusTextKey(serverName, connectionStatus, initializing) as TranslationKeys, + dotClass: cn( + getStatusColor(serverName, connectionStatus, initializing), + (isInitializing || serverStatus?.connectionState === 'connecting') && 'animate-pulse', + ), + }; } interface Props { @@ -79,7 +76,7 @@ export default function McpSection({ item }: Props) { } = useMCPServerManager(); const [oauthOpen, setOauthOpen] = useState(false); const [oauthUrl, setOauthUrl] = useState(null); - const [prevConnected, setPrevConnected] = useState(false); + const [prevReadyForAgent, setPrevReadyForAgent] = useState(false); const [autoSelectPending, setAutoSelectPending] = useState(false); const { mcpServersMap, mcpToolsLoading } = useAgentPanelContext(); const { agentsConfig } = useGetAgentsConfig(); @@ -136,7 +133,7 @@ export default function McpSection({ item }: Props) { * runtime heal keeps them active, and per-tool updates could never replace * the legacy entry. Tokens and other servers' entries pass through. */ - const toCurrentToolId = useCallback( + const toNormalizedToolId = useCallback( (entry: string): string => { const normalizedName = normalizeServerName(serverName); if ( @@ -172,6 +169,44 @@ export default function McpSection({ item }: Props) { [serverName, serverToken, serverAllToken, mcpServersMap], ); + /** + * Second migration stage: catalog keys drop a redundant leading server-name + * prefix, so a pre-strip persisted id would show its tool unchecked and a + * per-tool toggle could silently drop it from the selection. The rewrite is + * identity-verified — it only lands when the stripped catalog entry records + * this exact raw name as its upstream tool — so a stale id for a removed + * tool can never migrate onto a different sibling. + */ + /** Constant-time lookups for the migration below — the form heal calls it + * per persisted key, so linear catalog scans go O(options × tools). */ + const toolsById = useMemo(() => new Map(tools.map((tool) => [tool.tool_id, tool])), [tools]); + + const toStrippedToolId = useCallback( + (entry: string): string => { + if (entry === serverToken || entry === serverAllToken || toolsById.has(entry)) { + return entry; + } + const normalizedName = normalizeServerName(serverName); + const [toolPart, parsed] = splitMCPToolKey(entry, [normalizedName]); + if (parsed !== normalizedName) { + return entry; + } + const strippedPart = stripServerNamePrefix(toolPart, normalizedName); + if (strippedPart === toolPart) { + return entry; + } + const strippedId = `${strippedPart}${Constants.mcp_delimiter}${normalizedName}`; + const target = toolsById.get(strippedId); + return target?.metadata.serverToolName === toolPart ? strippedId : entry; + }, + [serverName, serverToken, serverAllToken, toolsById], + ); + + const toCurrentToolId = useCallback( + (entry: string): string => toStrippedToolId(toNormalizedToolId(entry)), + [toNormalizedToolId, toStrippedToolId], + ); + const isServerSelection = useCallback( (token: string): boolean => { const allServerNames = Array.from(new Set([...mcpServersMap.keys(), serverName])); @@ -296,21 +331,27 @@ export default function McpSection({ item }: Props) { const configDialogProps = getConfigDialogProps(); const connectionState = statusIconProps?.serverStatus?.connectionState; const isInitializing = statusIconProps?.isInitializing ?? false; - const statusDisplay = getStatusDisplay(connectionState, isInitializing, liveServer.isConfigured); + const statusDisplay = getStatusDisplay( + serverName, + statusIconProps?.serverStatus, + isInitializing, + liveServer.isConfigured, + ); /** A connected server's tools arrive with the (cold-cache) MCP tools fetch, and * the server is also briefly toolless while initializing — show a skeleton in * both cases instead of a misleading "no tools" message. */ const toolsLoading = !hasTools && (mcpToolsLoading || isInitializing || connectionState === 'connecting'); const isConnected = connectionState === 'connected' || liveServer.isConnected === true; + const isReadyForAgent = liveServer.isReadyForAgent ?? isConnected; const isBusy = isInitializing || connectionState === 'connecting'; - /** Close + clear the OAuth dialog once the server connects, and don't let it + /** Close + clear the OAuth dialog once the server is ready, and don't let it * reopen on its own if the connection later drops. No useEffect — adjust state * during render by comparing against the previous connection result. */ - if (prevConnected !== isConnected) { - setPrevConnected(isConnected); - if (isConnected) { + if (prevReadyForAgent !== isReadyForAgent) { + setPrevReadyForAgent(isReadyForAgent); + if (isReadyForAgent) { setOauthOpen(false); setOauthUrl(null); } @@ -331,7 +372,7 @@ export default function McpSection({ item }: Props) { const initConnectionDeferred = isConnectionDeferred(serverName); const requestScoped = liveServer.requestScoped === true; const runtimeToolsAvailable = - !hasTools && !toolsLoading && (isWildcardAttached || (requestScoped && isConnected)); + !hasTools && !toolsLoading && (isWildcardAttached || (requestScoped && isReadyForAgent)); const runtimeToolsMessage = isWildcardAttached ? 'com_ui_tools_mcp_runtime_tools' : 'com_ui_tools_mcp_runtime_tools_available'; @@ -408,19 +449,19 @@ export default function McpSection({ item }: Props) { aria-hidden="true" /> - {localize(statusDisplay.labelKey)} + {localize(statusDisplay.labelKey, { 0: serverName })}
- {isConnected && statusIconProps && } + {isReadyForAgent && statusIconProps && } - {/* Connect collapses smoothly once connected. Its top spacing lives inside + {/* Connect collapses smoothly once ready. Its top spacing lives inside * the reveal so the parent's flex gap never leaves a hole when it's gone, * and the auto-height dialog follows the grid-rows tween in one motion. */}
@@ -429,8 +470,8 @@ export default function McpSection({ item }: Props) { variant="submit" className="mt-5 w-full gap-2" disabled={isBusy} - tabIndex={isConnected ? -1 : undefined} - aria-hidden={isConnected || undefined} + tabIndex={isReadyForAgent ? -1 : undefined} + aria-hidden={isReadyForAgent || undefined} onClick={handleConnect} > {isBusy && } @@ -575,7 +616,7 @@ export default function McpSection({ item }: Props) { {configDialogProps && } { ); }); - test('clicking a connected request-scoped zero-tool server attaches its runtime wildcard', () => { + test('clicking a ready request-scoped zero-tool server attaches its runtime wildcard', () => { mockMcpServersMap = new Map([ [ 'runtime', @@ -237,7 +237,8 @@ describe('ToolsMarketplaceDialog', () => { serverName: 'runtime', tools: [], isConfigured: true, - isConnected: true, + isConnected: false, + isReadyForAgent: true, requestScoped: true, metadata: { name: 'runtime', pluginKey: 'runtime', description: '' }, }, @@ -272,6 +273,7 @@ describe('ToolsMarketplaceDialog', () => { tools: [], isConfigured: true, isConnected: true, + isReadyForAgent: true, requestScoped: true, metadata: { name: 'runtime', pluginKey: 'runtime', description: '' }, }, diff --git a/client/src/components/SidePanel/MCPBuilder/MCPBuilderPanel.tsx b/client/src/components/SidePanel/MCPBuilder/MCPBuilderPanel.tsx index ae7b0532a1d..cd84fde546f 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPBuilderPanel.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPBuilderPanel.tsx @@ -1,17 +1,37 @@ -import { useState, useRef, useMemo } from 'react'; +import { useState, useRef, useMemo, useEffect } from 'react'; import { Plus } from 'lucide-react'; +import { useRecoilValue } from 'recoil'; +import { useLocation } from 'react-router-dom'; import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider'; import { Button, FilterInput, OGDialogTrigger, TooltipAnchor } from '@librechat/client'; -import { useLocalize, useMCPServerManager, useHasAccess, useAuthContext } from '~/hooks'; +import { + useLocalize, + useMCPServerManager, + useHasAccess, + useAuthContext, + activateCatalog, +} from '~/hooks'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; import { PanelFooter, PanelContent } from '~/components/ui'; import MCPServerCardSkeleton from './MCPServerCardSkeleton'; import MCPAdminSettings from './MCPAdminSettings'; import MCPServerDialog from './MCPServerDialog'; import MCPServerList from './MCPServerList'; +import store from '~/store'; export default function MCPBuilderPanel() { const localize = useLocalize(); + const location = useLocation(); + /** The panel stays mounted while the sidebar is hidden (collapsed, mobile + * drawer, or the insights route collapsing it), so only a visible panel + * releases its catalog ahead of the background warmup schedule */ + const sidebarExpanded = useRecoilValue(store.sidebarExpanded); + const panelVisible = sidebarExpanded && !location.pathname.startsWith('/insights'); + useEffect(() => { + if (panelVisible) { + activateCatalog('mcpServers'); + } + }, [panelVisible]); const { user } = useAuthContext(); const { availableMCPServers, isLoading, getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager(); diff --git a/client/src/components/SidePanel/MCPBuilder/MCPCardActions.spec.tsx b/client/src/components/SidePanel/MCPBuilder/MCPCardActions.spec.tsx index 6067c16ff8d..e21a057f66a 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPCardActions.spec.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPCardActions.spec.tsx @@ -47,4 +47,59 @@ describe('MCPCardActions', () => { expect(revokeButton).toHaveClass('hover:text-text-secondary'); expect(revokeButton.querySelector('svg')).toHaveClass('text-text-destructive'); }); + + test.each([ + ['disconnected', 'com_nav_mcp_connect'], + ['error', 'com_nav_mcp_connect'], + ['connected', 'com_nav_mcp_reconnect'], + ] as const)( + 'does not render a manual connection action when %s and on-demand', + (state, label) => { + render( + , + ); + + expect(screen.queryByRole('button', { name: label })).not.toBeInTheDocument(); + }, + ); + + test('keeps custom-variable configuration available while an on-demand server is idle', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: 'com_ui_configure' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'com_nav_mcp_connect' })).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/SidePanel/MCPBuilder/MCPCardActions.tsx b/client/src/components/SidePanel/MCPBuilder/MCPCardActions.tsx index 6becb23931f..14a5770ab00 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPCardActions.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPCardActions.tsx @@ -125,7 +125,7 @@ export default function MCPCardActions({ )} {/* Connect button - for disconnected or error states */} - {(isDisconnected || isError) && ( + {(isDisconnected || isError) && !serverStatus?.requestScoped && ( )} - {/* Configure button - for connected servers with custom vars */} - {isConnected && hasCustomUserVars && ( + {/* On-demand servers stay idle between requests, so their user variables + must remain configurable without a live transport connection. */} + {(isConnected || serverStatus?.requestScoped) && hasCustomUserVars && ( ({ + useLocalize: () => (key: string) => key, +})); + +jest.mock('@librechat/client', () => ({ + Spinner: (props: React.ComponentProps<'span'>) => , +})); + +describe('MCPStatusBadge', () => { + test.each(['disconnected', 'connected', 'error'] as const)( + 'renders the %s request-scoped state as on-demand', + (connectionState) => { + const serverStatus: MCPServerStatus = { + connectionState, + requiresOAuth: true, + requestScoped: true, + }; + + render(); + + expect(screen.getByRole('status')).toHaveTextContent('com_nav_mcp_status_on_demand'); + expect(getStatusDotColor(serverStatus)).toBe('bg-status-info'); + }, + ); + + it('preserves the active connecting state for a request-scoped OAuth flow', () => { + const serverStatus: MCPServerStatus = { + connectionState: 'connecting', + requiresOAuth: true, + requestScoped: true, + }; + + render(); + + expect(screen.getByRole('status')).toHaveTextContent('com_nav_mcp_status_connecting'); + }); +}); diff --git a/client/src/components/SidePanel/MCPBuilder/MCPStatusBadge.tsx b/client/src/components/SidePanel/MCPBuilder/MCPStatusBadge.tsx index fd3eefa769f..c3306d8b86d 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPStatusBadge.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPStatusBadge.tsx @@ -1,5 +1,5 @@ import { Spinner } from '@librechat/client'; -import { Check, PlugZap } from 'lucide-react'; +import { Check, PlugZap, Zap } from 'lucide-react'; import type { MCPServerStatus } from 'librechat-data-provider'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -15,7 +15,7 @@ interface MCPStatusBadgeProps { * * Unified color system: * - Green: Connected/Active (success) - * - Blue: Connecting/In-progress + * - Blue: Connecting/In-progress or request-scoped on-demand * - Amber: Needs user action (OAuth required) * - Gray: Disconnected/Inactive (neutral) * - Red: Error @@ -66,6 +66,15 @@ export default function MCPStatusBadge({ ); } + if (serverStatus.requestScoped) { + return ( +
+
+ ); + } + // Disconnected state - check if needs action if (connectionState === 'disconnected') { if (requiresOAuth) { @@ -121,7 +130,7 @@ export default function MCPStatusBadge({ * * Colors: * - Green: Connected - * - Blue: Connecting/Initializing + * - Blue: Connecting/Initializing or request-scoped on-demand * - Amber: Needs action (OAuth required while disconnected) * - Gray: Disconnected (neutral) * - Red: Error @@ -144,6 +153,10 @@ export function getStatusDotColor( return 'bg-status-info'; } + if (serverStatus.requestScoped) { + return 'bg-status-info'; + } + if (connectionState === 'connected') { return 'bg-status-success'; } @@ -153,8 +166,10 @@ export function getStatusDotColor( } if (connectionState === 'disconnected') { - // Needs OAuth = amber, otherwise gray - return requiresOAuth ? 'bg-status-warning' : 'bg-status-neutral'; + if (requiresOAuth) { + return 'bg-status-warning'; + } + return 'bg-status-neutral'; } return 'bg-status-neutral'; diff --git a/client/src/components/Web/Citation.tsx b/client/src/components/Web/Citation.tsx index d6661e62161..67f3ffafba5 100644 --- a/client/src/components/Web/Citation.tsx +++ b/client/src/components/Web/Citation.tsx @@ -11,6 +11,7 @@ import { useLocalize } from '~/hooks'; interface FileCitationMetadata { fileBytes?: number; fileType?: string; + storageType?: string; } interface FileCitationSource { @@ -282,6 +283,7 @@ export function CompositeCitation(props: CompositeCitationProps) { pages={filePages} pageRelevance={filePageRelevance} fileType={fileMeta?.fileType} + fileSource={fileMeta?.storageType} fileSize={fileMeta?.fileBytes} /> )} @@ -358,6 +360,7 @@ export function Citation(props: CitationComponentProps) { pages={filePages} pageRelevance={filePageRelevance} fileType={fileMeta?.fileType} + fileSource={fileMeta?.storageType} fileSize={fileMeta?.fileBytes} /> )} diff --git a/client/src/components/Web/__tests__/Citation.test.tsx b/client/src/components/Web/__tests__/Citation.test.tsx index 6ec16d4c04d..d25cb2d7611 100644 --- a/client/src/components/Web/__tests__/Citation.test.tsx +++ b/client/src/components/Web/__tests__/Citation.test.tsx @@ -29,9 +29,19 @@ jest.mock('~/hooks', () => ({ jest.mock('~/components/Chat/Messages/Content/FilePreviewDialog', () => ({ __esModule: true, - default: ({ open, fileId, fileName }: { open: boolean; fileId?: string; fileName: string }) => + default: ({ + open, + fileId, + fileName, + fileSource, + }: { + open: boolean; + fileId?: string; + fileName: string; + fileSource?: string; + }) => open ? ( -
+
{fileName}
) : null, @@ -76,6 +86,7 @@ describe('Citation', () => { metadata: { fileBytes: 2048, fileType: 'application/pdf', + storageType: 'text', }, pageRelevance: { 1: 0.92 }, pages: [1], @@ -107,6 +118,7 @@ describe('Citation', () => { fireEvent.click(fileButton); expect(screen.getByTestId('file-preview-dialog')).toHaveAttribute('data-file-id', 'file-123'); + expect(screen.getByTestId('file-preview-dialog')).toHaveAttribute('data-file-source', 'text'); }); it('keeps standalone web citations as links', () => { diff --git a/client/src/data-provider/Files/queries.ts b/client/src/data-provider/Files/queries.ts index 81f161d6280..da6981571e0 100644 --- a/client/src/data-provider/Files/queries.ts +++ b/client/src/data-provider/Files/queries.ts @@ -3,8 +3,13 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { FileSources, QueryKeys, DynamicQueryKeys, dataService } from 'librechat-data-provider'; import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query'; import type t from 'librechat-data-provider'; +import { + addFileToCache, + getDownloadFilename, + registerDownloadFilename, + unregisterDownloadFilename, +} from '~/utils'; import { isEphemeralAgent } from '~/common'; -import { addFileToCache } from '~/utils'; import store from '~/store'; export const useGetFiles = ( @@ -56,6 +61,7 @@ export const useGetFileConfig = ( type FileDownloadOptions = { source?: string | null; direct?: boolean; + purpose?: 'download' | 'preview'; }; export const isDirectDownloadSource = (source?: string | null): boolean => @@ -65,6 +71,7 @@ export const revokeDownloadURL = (url?: string | null): void => { if (!url?.startsWith('blob:')) { return; } + unregisterDownloadFilename(url); window.URL.revokeObjectURL(url); }; @@ -75,7 +82,13 @@ export const useFileDownload = ( ): QueryObserverResult => { const queryClient = useQueryClient(); return useQuery( - [QueryKeys.fileDownload, file_id, options.source ?? '', options.direct ?? true], + [ + QueryKeys.fileDownload, + file_id, + options.source ?? '', + options.direct ?? true, + options.purpose ?? 'download', + ], async () => { if (!userId || !file_id) { console.warn('No user ID provided for file download'); @@ -104,6 +117,10 @@ export const useFileDownload = ( return downloadURL; } + registerDownloadFilename( + downloadURL, + getDownloadFilename(metadata.filename, metadata.file_id, metadata.source), + ); addFileToCache(queryClient, metadata); } catch (e) { console.error('Error parsing file metadata, skipped updating file query cache', e); @@ -126,9 +143,10 @@ export const useFileDownload = ( export const useSharedFileDownload = ( shareId?: string, file_id?: string, + purpose: 'download' | 'preview' = 'download', ): QueryObserverResult => { return useQuery( - [QueryKeys.fileDownload, 'share', shareId ?? '', file_id ?? ''], + [QueryKeys.fileDownload, 'share', shareId ?? '', file_id ?? '', purpose], async () => { if (!shareId || !file_id) { return; diff --git a/client/src/data-provider/Subagents/queries.test.ts b/client/src/data-provider/Subagents/queries.test.ts index 37da42d9660..61bdd883ec2 100644 --- a/client/src/data-provider/Subagents/queries.test.ts +++ b/client/src/data-provider/Subagents/queries.test.ts @@ -46,13 +46,22 @@ describe('subagent thread refresh policy', () => { expect(subagentThreadRefetchInterval(prior, 1_000, 1_000, 'new-task')).toBe(false); }); + it('stops polling an older API view once the exact task response exists', () => { + const rollingDeployView = { + ...view('running'), + messages: [{ messageId: 'selected:assistant' }], + } as SubagentThreadView; + + expect(subagentThreadRefetchInterval(rollingDeployView, 1_000, 500, 'selected')).toBe(false); + }); + it('treats only readiness-window 404s as pending', () => { expect(isSubagentReadinessPending({ response: { status: 404 } }, 1_000, 500)).toBe(true); expect(isSubagentReadinessPending({ response: { status: 404 } }, 1_000, 1_000)).toBe(false); expect(isSubagentReadinessPending({ response: { status: 500 } }, 1_000, 500)).toBe(false); }); - it('refetches a terminal thread when a new invocation continues it', () => { + it('keys the bounded activity projection by the selected invocation', () => { const refetch = jest.fn(); mockUseQuery.mockReturnValue({ data: view('completed'), @@ -64,8 +73,19 @@ describe('subagent thread refresh policy', () => { { initialProps: { taskId: 'task-1' } }, ); - expect(refetch).not.toHaveBeenCalled(); + expect(mockUseQuery.mock.calls.at(-1)?.[0]).toEqual([ + 'subagentThread', + 'parent-conversation', + 'child-thread', + 'task-1', + ]); rerender({ taskId: 'task-2' }); - expect(refetch).toHaveBeenCalledTimes(1); + expect(mockUseQuery.mock.calls.at(-1)?.[0]).toEqual([ + 'subagentThread', + 'parent-conversation', + 'child-thread', + 'task-2', + ]); + expect(refetch).not.toHaveBeenCalled(); }); }); diff --git a/client/src/data-provider/Subagents/queries.ts b/client/src/data-provider/Subagents/queries.ts index cec1181de2f..81b102e3ccd 100644 --- a/client/src/data-provider/Subagents/queries.ts +++ b/client/src/data-provider/Subagents/queries.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { QueryKeys, dataService } from 'librechat-data-provider'; import type { UseQueryOptions, QueryObserverResult } from '@tanstack/react-query'; @@ -29,6 +29,16 @@ export const subagentThreadRefetchInterval = ( ) { return now < readinessDeadline ? ACTIVE_THREAD_REFRESH_MS : false; } + // During a rolling deploy, an older replica can return a thread-wide status + // without the task-scoped activity projection. The exact assistant row is + // nevertheless authoritative evidence that this selected invocation ended. + if ( + expectedTaskId != null && + view?.activity == null && + view?.messages.some((message) => message.messageId === `${expectedTaskId}:assistant`) + ) { + return false; + } if (view == null || view.status === 'dispatched') { return now < readinessDeadline ? ACTIVE_THREAD_REFRESH_MS : false; } @@ -62,10 +72,9 @@ export const useSubagentThreadQuery = ( () => ({ key: readinessKey, deadline: Date.now() + CHILD_READY_POLL_WINDOW_MS }), [readinessKey], ); - const previousTaskId = useRef(taskId); const query = useQuery( - [QueryKeys.subagentThread, parentConversationId, threadId], - () => dataService.getSubagentThread(parentConversationId, threadId), + [QueryKeys.subagentThread, parentConversationId, threadId, taskId], + () => dataService.getSubagentThread(parentConversationId, threadId, taskId), { enabled: parentConversationId !== '' && threadId !== '', retry: false, @@ -75,12 +84,6 @@ export const useSubagentThreadQuery = ( ...config, }, ); - const { refetch } = query; - useEffect(() => { - if (previousTaskId.current === taskId) return; - previousTaskId.current = taskId; - void refetch(); - }, [taskId, refetch]); return { ...query, diff --git a/client/src/hooks/Config/__tests__/useAppStartup.spec.tsx b/client/src/hooks/Config/__tests__/useAppStartup.spec.tsx index b5ad9afa696..8a82141d45f 100644 --- a/client/src/hooks/Config/__tests__/useAppStartup.spec.tsx +++ b/client/src/hooks/Config/__tests__/useAppStartup.spec.tsx @@ -9,6 +9,7 @@ type CloudFrontRetryOptions = { getAuthorizationHeader: () => string | undefined const mockUseHasAccess = jest.fn(); const mockUseMCPServersQuery = jest.fn(); const mockUseMCPToolsQuery = jest.fn(); +const mockUseCatalogReady = jest.fn(); const mockInstallCloudFrontImageRetry = jest.fn( (_startupConfig: unknown, _options: CloudFrontRetryOptions): (() => void) => () => @@ -31,6 +32,7 @@ jest.mock('librechat-data-provider', () => { jest.mock('~/hooks', () => ({ useHasAccess: (args: unknown) => mockUseHasAccess(args), + useCatalogReady: (id: unknown) => mockUseCatalogReady(id), })); jest.mock('~/data-provider', () => ({ @@ -71,11 +73,12 @@ const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} ); -describe('useAppStartup — MCP permission gating', () => { +describe('useAppStartup: MCP permission gating', () => { beforeEach(() => { mockInstallCloudFrontImageRetry.mockClear(); mockUseMCPServersQuery.mockReturnValue({ data: undefined, isLoading: false }); mockUseMCPToolsQuery.mockReturnValue({ data: undefined, isLoading: false }); + mockUseCatalogReady.mockReturnValue(true); }); it('checks the MCP_SERVERS.USE permission via useHasAccess', () => { @@ -98,6 +101,18 @@ describe('useAppStartup — MCP permission gating', () => { expect(mockUseMCPToolsQuery).toHaveBeenCalledWith({ enabled: false }); }); + it('suppresses MCP queries while background catalog warmup has not released them', () => { + mockUseHasAccess.mockReturnValue(true); + mockUseCatalogReady.mockReturnValue(false); + + renderHook(() => useAppStartup({ startupConfig: undefined, user: mockUser }), { wrapper }); + + expect(mockUseCatalogReady).toHaveBeenCalledWith('mcpServers'); + expect(mockUseCatalogReady).toHaveBeenCalledWith('mcpTools'); + expect(mockUseMCPServersQuery).toHaveBeenCalledWith({ enabled: false }); + expect(mockUseMCPToolsQuery).toHaveBeenCalledWith({ enabled: false }); + }); + it('enables servers query and tools query when permission granted, servers loaded, and user present', () => { mockUseHasAccess.mockReturnValue(true); mockUseMCPServersQuery.mockReturnValue({ diff --git a/client/src/hooks/Config/useAppStartup.ts b/client/src/hooks/Config/useAppStartup.ts index 6df61d2230b..bf644ed9df8 100644 --- a/client/src/hooks/Config/useAppStartup.ts +++ b/client/src/hooks/Config/useAppStartup.ts @@ -13,7 +13,7 @@ import type { TStartupConfig, TUser } from 'librechat-data-provider'; import { useMCPToolsQuery, useMCPServersQuery } from '~/data-provider'; import { cleanupTimestampedStorage } from '~/utils/timestamps'; import useSpeechSettingsInit from './useSpeechSettingsInit'; -import { useHasAccess } from '~/hooks'; +import { useHasAccess, useCatalogReady } from '~/hooks'; import store from '~/store'; export default function useAppStartup({ @@ -30,13 +30,18 @@ export default function useAppStartup({ }); useSpeechSettingsInit(!!user); + /** MCP catalogs are background-warmed: the queries stay off the startup + * path until warmup releases them (or an MCP UI activates them). */ + const mcpServersReady = useCatalogReady('mcpServers'); + const mcpToolsReady = useCatalogReady('mcpTools'); const { data: loadedServers, isLoading: serversLoading } = useMCPServersQuery({ - enabled: canUseMcp, + enabled: canUseMcp && mcpServersReady, }); useMCPToolsQuery({ enabled: canUseMcp && + mcpToolsReady && !serversLoading && !!loadedServers && Object.keys(loadedServers).length > 0 && diff --git a/client/src/hooks/MCP/__tests__/useMCPIconMap.spec.tsx b/client/src/hooks/MCP/__tests__/useMCPIconMap.spec.tsx new file mode 100644 index 00000000000..66b676f8855 --- /dev/null +++ b/client/src/hooks/MCP/__tests__/useMCPIconMap.spec.tsx @@ -0,0 +1,40 @@ +import { act } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; +import { activateCatalog, resetCatalogWarmup } from '../../useCatalogWarmup'; +import { useMCPIconMap, useMCPServerNames } from '../useMCPIconMap'; + +const mockUseMCPServersQuery = jest.fn(); +jest.mock('~/data-provider', () => ({ + useMCPServersQuery: (config: unknown) => mockUseMCPServersQuery(config), +})); + +describe('useMCPIconMap', () => { + beforeEach(() => { + resetCatalogWarmup(); + mockUseMCPServersQuery.mockReturnValue({ data: undefined }); + mockUseMCPServersQuery.mockClear(); + }); + + it('keeps the servers query disabled until warmup releases the catalog', () => { + renderHook(() => { + useMCPIconMap(); + useMCPServerNames(); + }); + + expect(mockUseMCPServersQuery).toHaveBeenCalledWith({ enabled: false }); + }); + + it('enables the servers query once the catalog is active', () => { + renderHook(() => { + useMCPIconMap(); + useMCPServerNames(); + }); + expect(mockUseMCPServersQuery).toHaveBeenLastCalledWith({ enabled: false }); + + act(() => { + activateCatalog('mcpServers'); + }); + + expect(mockUseMCPServersQuery).toHaveBeenLastCalledWith({ enabled: true }); + }); +}); diff --git a/client/src/hooks/MCP/useMCPIconMap.ts b/client/src/hooks/MCP/useMCPIconMap.ts index 00cc7a75e1c..4e61668d086 100644 --- a/client/src/hooks/MCP/useMCPIconMap.ts +++ b/client/src/hooks/MCP/useMCPIconMap.ts @@ -1,9 +1,13 @@ import { useMemo } from 'react'; import { normalizeServerName } from 'librechat-data-provider'; +import { useCatalogReady } from '../useCatalogWarmup'; import { useMCPServersQuery } from '~/data-provider'; +/** These observers mount from rendered messages, so they must not pull the + * server catalog onto the first-render path ahead of the warmup schedule. */ export function useMCPIconMap(): Map { - const { data: servers } = useMCPServersQuery(); + const mcpServersReady = useCatalogReady('mcpServers'); + const { data: servers } = useMCPServersQuery({ enabled: mcpServersReady }); return useMemo(() => { const map = new Map(); @@ -26,6 +30,7 @@ export function useMCPIconMap(): Map { * so they can be matched against a key. The config is keyed by the raw name. */ export function useMCPServerNames(): string[] { - const { data: servers } = useMCPServersQuery(); + const mcpServersReady = useCatalogReady('mcpServers'); + const { data: servers } = useMCPServersQuery({ enabled: mcpServersReady }); return useMemo(() => (servers ? Object.keys(servers).map(normalizeServerName) : []), [servers]); } diff --git a/client/src/hooks/MCP/useMCPServerManager.ts b/client/src/hooks/MCP/useMCPServerManager.ts index 910bba288be..e72186c64c7 100644 --- a/client/src/hooks/MCP/useMCPServerManager.ts +++ b/client/src/hooks/MCP/useMCPServerManager.ts @@ -34,7 +34,13 @@ import { isTerminalMCPOAuthPollingError, shouldUseMCPConnectionStatus, } from './polling'; -import { useLocalize, useHasAccess, useMCPSelect, useMCPConnectionStatus } from '~/hooks'; +import { + useLocalize, + useHasAccess, + useMCPSelect, + useCatalogReady, + useMCPConnectionStatus, +} from '~/hooks'; import { useGetStartupConfig, useMCPServersQuery } from '~/data-provider'; import { mcpServerInitStatesAtom, getServerInitState } from '~/store/mcp'; import { getMCPReinitializeErrorMessage } from './errors'; @@ -66,12 +72,16 @@ export function useMCPServerManager({ permissionType: PermissionTypes.MCP_SERVERS, permission: Permissions.USE, }); + /** MCP catalogs are background-warmed: the server list powers nav-link + * visibility and the chat-menu select, none of which gate first paint. */ + const mcpServersReady = useCatalogReady('mcpServers'); + const mcpEnabled = canUseMcp && mcpServersReady; - const { data: loadedServers, isLoading } = useMCPServersQuery({ enabled: canUseMcp }); + const { data: loadedServers, isLoading } = useMCPServersQuery({ enabled: mcpEnabled }); // Fetch effective permissions for all MCP servers const { data: permissionsMap } = useGetAllEffectivePermissionsQuery(ResourceType.MCPSERVER, { - enabled: canUseMcp, + enabled: mcpEnabled, }); const [isConfigModalOpen, setIsConfigModalOpen] = useState(false); @@ -169,9 +179,26 @@ export function useMCPServerManager({ // Poll intervals are kept local (not serializable) const pollIntervalsRef = useRef({}); - const { connectionStatus } = useMCPConnectionStatus({ + const { connectionStatus: polledConnectionStatus } = useMCPConnectionStatus({ enabled: !isLoading && availableMCPServers.length > 0, }); + const connectionStatus = useMemo(() => { + if (!polledConnectionStatus) { + return polledConnectionStatus; + } + + let changed = false; + const nextStatus: MCPConnectionStatusResponse['connectionStatus'] = {}; + for (const [serverName, status] of Object.entries(polledConnectionStatus)) { + if (status.requestScoped === true || loadedServers?.[serverName]?.requestScoped !== true) { + nextStatus[serverName] = status; + continue; + } + changed = true; + nextStatus[serverName] = { ...status, requestScoped: true }; + } + return changed ? nextStatus : polledConnectionStatus; + }, [polledConnectionStatus, loadedServers]); const updateServerInitState = useCallback( (serverName: string, updates: Partial) => { diff --git a/client/src/hooks/SSE/__tests__/useEventHandlers.spec.ts b/client/src/hooks/SSE/__tests__/useEventHandlers.spec.ts index 49f5b37f14b..08fb6943678 100644 --- a/client/src/hooks/SSE/__tests__/useEventHandlers.spec.ts +++ b/client/src/hooks/SSE/__tests__/useEventHandlers.spec.ts @@ -4,6 +4,7 @@ import { buildCreatedInitialResponse, getExistingConversationAbortMessages, isInitialNewConversationSubmission, + mergeErrorMessages, mergeRegenerateFinalMessages, startedAsNewConversation, } from '~/hooks/SSE/useEventHandlers'; @@ -230,3 +231,61 @@ describe('getExistingConversationAbortMessages', () => { ).toEqual(['user-1']); }); }); + +describe('mergeErrorMessages', () => { + const message = (messageId: string, isCreatedByUser = false) => + ({ + messageId, + conversationId: 'conversation-1', + isCreatedByUser, + text: messageId, + }) as TMessage; + + it('adds the request and error for a normal submission', () => { + const userMessage = message('user-1', true); + const errorMessage = message('assistant-error'); + + expect( + mergeErrorMessages({ + messages: [message('previous-response')], + userMessage, + errorMessage, + }).map(({ messageId }) => messageId), + ).toEqual(['previous-response', 'user-1', 'assistant-error']); + }); + + it('preserves regeneration history without duplicating its user', () => { + const userMessage = message('user-1', true); + const originalResponse = message('assistant-1'); + const laterUser = message('user-2', true); + const laterResponse = message('assistant-2'); + const errorMessage = message('assistant-1_'); + + expect( + mergeErrorMessages({ + messages: [userMessage], + regenerateMessages: [userMessage, originalResponse, laterUser, laterResponse], + userMessage, + errorMessage, + isRegenerate: true, + }).map(({ messageId }) => messageId), + ).toEqual(['user-1', 'assistant-1', 'user-2', 'assistant-2', 'assistant-1_']); + }); + + it('replaces an edited response error that intentionally reuses its id', () => { + const userMessage = message('user-1', true); + const originalResponse = message('assistant-1'); + const errorMessage = { ...originalResponse, text: 'Regeneration failed', error: true }; + + const merged = mergeErrorMessages({ + messages: [userMessage], + regenerateMessages: [userMessage, originalResponse], + userMessage, + errorMessage, + isRegenerate: true, + }); + + expect(merged.map(({ messageId }) => messageId)).toEqual(['user-1', 'assistant-1']); + expect(merged[1]).toEqual(errorMessage); + }); +}); diff --git a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts index 4f09207441c..49fd41fe8e6 100644 --- a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts +++ b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts @@ -2964,14 +2964,19 @@ describe('useResumableSSE', () => { unmount(); }); - /** - * Regenerate: the new run's response id is not in the loaded history yet, so the - * parent-based fallback lands on the answer being REPLACED. Preserving that row's - * content would make the regenerated run's deltas append to the stale answer, so an - * empty snapshot must still clear a row we only matched heuristically. - */ - it('does not preserve content on a row matched only by the parent fallback', async () => { - const submission = buildSubmission(); + it('does not reuse an older response that only shares the user parent', async () => { + const submission = buildSubmission({ + initialResponse: { + messageId: 'resp-1', + conversationId: CONV_ID, + text: '', + isCreatedByUser: false, + sender: 'Custom Assistant', + endpoint: 'azureOpenAI', + iconURL: 'https://example.com/assistant.png', + model: 'gpt-4.1', + }, + }); const chatHelpers = buildChatHelpers(); chatHelpers.getMessages.mockReturnValue([ { @@ -3009,12 +3014,27 @@ describe('useResumableSSE', () => { }); }); - const synced = chatHelpers.setMessages.mock.calls + const syncedMessages = chatHelpers.setMessages.mock.calls .map(([messages]) => messages as TMessage[]) .reverse() - .find((messages) => messages?.some((m) => m.messageId === 'resp-previous')) - ?.find((m) => m.messageId === 'resp-previous'); - expect(synced?.content).toEqual([]); + .find((messages) => messages?.some((m) => m.messageId === 'resp-regenerated')); + expect(syncedMessages?.find((m) => m.messageId === 'resp-previous')?.content).toEqual([ + { type: 'text', text: 'the answer being regenerated' }, + ]); + expect(syncedMessages?.map((message) => message.messageId)).toEqual([ + 'msg-1', + 'resp-previous', + 'resp-regenerated', + ]); + expect(syncedMessages?.find((m) => m.messageId === 'resp-regenerated')).toEqual( + expect.objectContaining({ + content: [], + sender: 'Custom Assistant', + endpoint: 'azureOpenAI', + iconURL: 'https://example.com/assistant.png', + model: 'gpt-4.1', + }), + ); unmount(); }); @@ -4128,3 +4148,250 @@ describe('useResumableSSE', () => { unmount(); }); }); + +describe('useResumableSSE - sync response identity', () => { + beforeEach(() => { + mockSSEInstances.length = 0; + mockSetIsSubmitting.mockClear(); + }); + + const emitSync = async ( + sse: MockSSEInstance, + aggregatedContent: TMessage['content'], + responseMessageId?: string, + sender?: string, + userMessage?: Partial, + ) => { + await act(async () => { + sse._emit('message', { + data: JSON.stringify({ + sync: true, + resumeState: { aggregatedContent, responseMessageId, sender, userMessage }, + }), + }); + }); + }; + + it('updates the submission-owned response when sync omits the response ID', async () => { + const userMessage = { + messageId: 'server-user-id', + conversationId: CONV_ID, + text: 'Hello', + isCreatedByUser: true, + } as TMessage; + const activeResponse = { + messageId: 'server-response-id', + parentMessageId: 'server-user-id', + conversationId: CONV_ID, + text: '', + content: [], + isCreatedByUser: false, + } as TMessage; + const submission = { + ...buildSubmission({ userMessage, initialResponse: activeResponse }), + resumeStreamId: CONV_ID, + } as TSubmission & { resumeStreamId: string }; + const chatHelpers = buildChatHelpers(); + chatHelpers.getMessages.mockReturnValue([userMessage, activeResponse]); + + const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers)); + await act(async () => { + await Promise.resolve(); + }); + + const aggregatedContent: TMessage['content'] = [ + { type: ContentTypes.TEXT, text: { value: 'Recovered answer' } }, + ]; + await emitSync(getLastSSE(), aggregatedContent); + + const updatedMessages = chatHelpers.setMessages.mock.calls.at(-1)?.[0] as TMessage[]; + expect(updatedMessages).toHaveLength(2); + expect(updatedMessages.find((message) => message.messageId === 'server-response-id')).toEqual({ + ...activeResponse, + content: aggregatedContent, + }); + expect( + updatedMessages.find((message) => message.messageId === 'server-user-id_'), + ).toBeUndefined(); + unmount(); + }); + + it('adds resumed sender metadata to an exact persisted response', async () => { + const userMessage = { + messageId: 'server-user-id', + conversationId: CONV_ID, + text: 'Hello', + isCreatedByUser: true, + } as TMessage; + const activeResponse = { + messageId: 'server-response-id', + parentMessageId: 'server-user-id', + conversationId: CONV_ID, + text: '', + content: [], + isCreatedByUser: false, + } as TMessage; + const submission = { + ...buildSubmission({ userMessage, initialResponse: activeResponse }), + resumeStreamId: CONV_ID, + } as TSubmission & { resumeStreamId: string }; + const chatHelpers = buildChatHelpers(); + chatHelpers.getMessages.mockReturnValue([userMessage, activeResponse]); + + const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers)); + await act(async () => { + await Promise.resolve(); + }); + + await emitSync(getLastSSE(), [], activeResponse.messageId, 'Restored Assistant'); + + const updatedMessages = chatHelpers.setMessages.mock.calls.at(-1)?.[0] as TMessage[]; + expect(updatedMessages[1]).toEqual({ + ...activeResponse, + sender: 'Restored Assistant', + }); + unmount(); + }); + + it('appends a missing submission-owned response after older regeneration siblings', async () => { + const userMessage = { + messageId: 'server-user-id', + conversationId: CONV_ID, + text: 'Hello', + isCreatedByUser: true, + } as TMessage; + const olderResponse = { + messageId: 'older-response-id', + parentMessageId: 'server-user-id', + conversationId: CONV_ID, + text: 'Earlier answer', + content: [{ type: 'text', text: { value: 'Earlier answer' } }], + isCreatedByUser: false, + } as TMessage; + const activeResponse = { + messageId: 'active-response-id', + parentMessageId: 'server-user-id', + conversationId: CONV_ID, + text: '', + content: [], + isCreatedByUser: false, + } as TMessage; + const submission = { + ...buildSubmission({ userMessage, initialResponse: activeResponse }), + isRegenerate: true, + resumeStreamId: CONV_ID, + } as TSubmission & { resumeStreamId: string }; + const chatHelpers = buildChatHelpers(); + chatHelpers.getMessages.mockReturnValue([userMessage, olderResponse]); + + const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers)); + await act(async () => { + await Promise.resolve(); + }); + + const aggregatedContent: TMessage['content'] = [ + { type: ContentTypes.TEXT, text: { value: 'Regenerated answer' } }, + ]; + await emitSync(getLastSSE(), aggregatedContent); + + const updatedMessages = chatHelpers.setMessages.mock.calls.at(-1)?.[0] as TMessage[]; + expect(updatedMessages.find((message) => message.messageId === 'older-response-id')).toEqual( + olderResponse, + ); + expect(updatedMessages.map((message) => message.messageId)).toEqual([ + 'server-user-id', + 'older-response-id', + 'active-response-id', + ]); + expect(updatedMessages.find((message) => message.messageId === 'active-response-id')).toEqual({ + ...activeResponse, + content: aggregatedContent, + }); + unmount(); + }); + + it('replaces the current-run placeholder without erasing its loaded content', async () => { + const userMessage = { + messageId: 'server-user-id', + conversationId: CONV_ID, + text: 'Hello', + isCreatedByUser: true, + } as TMessage; + const preliminaryResponse = { + messageId: 'server-user-id_', + parentMessageId: 'server-user-id', + conversationId: CONV_ID, + text: '', + content: [{ type: ContentTypes.TEXT, text: { value: 'Already streaming' } }], + sender: 'Assistant', + isCreatedByUser: false, + } as TMessage; + const submission = { + ...buildSubmission({ userMessage, initialResponse: preliminaryResponse }), + resumeStreamId: CONV_ID, + } as TSubmission & { resumeStreamId: string }; + const chatHelpers = buildChatHelpers(); + chatHelpers.getMessages.mockReturnValue([userMessage, preliminaryResponse]); + + const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers)); + await act(async () => { + await Promise.resolve(); + }); + + await emitSync(getLastSSE(), [], 'assigned-response-id'); + + const updatedMessages = chatHelpers.setMessages.mock.calls.at(-1)?.[0] as TMessage[]; + expect(updatedMessages.map((message) => message.messageId)).toEqual([ + 'server-user-id', + 'assigned-response-id', + ]); + expect(updatedMessages[1]).toEqual({ + ...preliminaryResponse, + messageId: 'assigned-response-id', + }); + unmount(); + }); + + it('replaces the current-run user when sync assigns both durable IDs', async () => { + const preliminaryUser = { + messageId: 'client-user-id', + parentMessageId: 'previous-response-id', + conversationId: CONV_ID, + text: 'Hello', + isCreatedByUser: true, + } as TMessage; + const preliminaryResponse = { + messageId: 'client-user-id_', + parentMessageId: preliminaryUser.messageId, + conversationId: CONV_ID, + text: '', + content: [], + isCreatedByUser: false, + } as TMessage; + const submission = { + ...buildSubmission({ userMessage: preliminaryUser, initialResponse: preliminaryResponse }), + resumeStreamId: CONV_ID, + } as TSubmission & { resumeStreamId: string }; + const chatHelpers = buildChatHelpers(); + chatHelpers.getMessages.mockReturnValue([preliminaryUser, preliminaryResponse]); + + const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers)); + await act(async () => { + await Promise.resolve(); + }); + + const assignedUser = { + ...preliminaryUser, + messageId: 'assigned-user-id', + }; + await emitSync(getLastSSE(), [], 'assigned-response-id', undefined, assignedUser); + + const updatedMessages = chatHelpers.setMessages.mock.calls.at(-1)?.[0] as TMessage[]; + expect(updatedMessages.map((message) => message.messageId)).toEqual([ + 'assigned-user-id', + 'assigned-response-id', + ]); + expect(updatedMessages[1]?.parentMessageId).toBe('assigned-user-id'); + unmount(); + }); +}); diff --git a/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx b/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx index 93fb20a0209..1af5beda979 100644 --- a/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx +++ b/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx @@ -290,6 +290,81 @@ describe('useResumeOnLoad', () => { expect(attached?.resumeGenerationCreatedAt).toBe(4242); }); + it('restores an externally started regeneration after history refreshes', 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 observedSubmissions: Array = []; + let messages = [rootUser]; + mockUseStreamStatus.mockReturnValue(INACTIVE_STATUS); + + const { rerender, queryClient } = renderUseResumeOnLoad({ + getMessages: () => messages, + onSubmission: (currentSubmission) => observedSubmissions.push(currentSubmission), + }); + await act(async () => { + await Promise.resolve(); + }); + + const invalidate = jest.spyOn(queryClient, 'invalidateQueries').mockResolvedValue(undefined); + messages = [rootUser, newerResponse, olderResponse]; + mockUseActiveJobs.mockReturnValue({ + data: { activeJobIds: [CONVERSATION_ID] }, + dataUpdatedAt: 2, + }); + mockUseStreamStatus.mockReturnValue({ + isSuccess: true, + isFetching: false, + data: { + active: true, + status: 'running', + createdAt: 4242, + streamId: CONVERSATION_ID, + resumeState: { + aggregatedContent: [{ type: ContentTypes.TEXT, text: 'regenerating' }], + responseMessageId: `${olderResponse.messageId}_`, + userMessage: { + messageId: rootUser.messageId, + conversationId: CONVERSATION_ID, + }, + }, + }, + }); + rerender(); + await act(async () => { + await Promise.resolve(); + }); + + expect(invalidate).toHaveBeenCalledWith({ + queryKey: [QueryKeys.messages, CONVERSATION_ID], + }); + const attached = observedSubmissions[observedSubmissions.length - 1]; + expect(attached?.isRegenerate).toBe(true); + expect(attached?.initialResponse?.messageId).toBe(`${olderResponse.messageId}_`); + expect(attached?.messages?.map((message) => message.messageId)).toEqual([ + rootUser.messageId, + newerResponse.messageId, + olderResponse.messageId, + ]); + expect(attached?.regenerateMessages?.map((message) => message.messageId)).toEqual([ + rootUser.messageId, + newerResponse.messageId, + olderResponse.messageId, + ]); + }); + it('re-arms once per run rather than on every poll of the active list', async () => { mockUseStreamStatus.mockReturnValue(INACTIVE_STATUS); const { rerender } = renderUseResumeOnLoad({ messages: [] }); @@ -771,6 +846,54 @@ describe('useResumeOnLoad', () => { ]); }); + it('does not claim an older sibling when resume state omits the response ID', async () => { + const observedSubmissions: Array = []; + const userMessage = buildUserMessage(CONVERSATION_ID); + const olderSibling = { + messageId: 'older-sibling-response', + parentMessageId: userMessage.messageId, + conversationId: CONVERSATION_ID, + text: 'Older sibling', + isCreatedByUser: false, + } as TMessage; + + mockUseStreamStatus.mockReturnValue({ + isSuccess: true, + isFetching: false, + data: { + active: true, + status: 'running', + streamId: CONVERSATION_ID, + resumeState: { + runSteps: [], + aggregatedContent: [{ type: 'text', text: 'Active branch streaming' }], + conversationId: CONVERSATION_ID, + userMessage: { + messageId: userMessage.messageId, + parentMessageId: userMessage.parentMessageId, + conversationId: CONVERSATION_ID, + text: userMessage.text, + }, + }, + }, + }); + + renderUseResumeOnLoad({ + messages: [userMessage, olderSibling], + onSubmission: (currentSubmission) => observedSubmissions.push(currentSubmission), + }); + + await act(async () => { + await Promise.resolve(); + }); + + const submission = observedSubmissions[observedSubmissions.length - 1]; + expect(submission?.initialResponse?.messageId).toBe(`${userMessage.messageId}_`); + expect((submission?.messages ?? []).map((message) => message.messageId)).toEqual([ + olderSibling.messageId, + ]); + }); + it('restores the branch that owns a pending OAuth resume user message', async () => { const rootUser = buildUserMessage(CONVERSATION_ID, 'root-user'); const branchOneResponse = { @@ -834,13 +957,16 @@ describe('useResumeOnLoad', () => { expect(observedSiblingIndexes[observedSiblingIndexes.length - 1]).toBe(1); }); - it('restores the assistant sibling selected by a pending regenerate response', async () => { + it('restores the regenerate branch without claiming its older response', async () => { const rootUser = buildUserMessage(CONVERSATION_ID, 'root-user'); const olderResponse = { messageId: 'older-response', parentMessageId: rootUser.messageId, conversationId: CONVERSATION_ID, text: 'Older response', + sender: 'Agent One', + model: 'gpt-5', + iconURL: 'https://example.com/agent-one.png', isCreatedByUser: false, } as TMessage; const newerResponse = { @@ -890,9 +1016,88 @@ describe('useResumeOnLoad', () => { expect(observedSiblingIndexes[observedSiblingIndexes.length - 1]).toBe(0); const submission = observedSubmissions[observedSubmissions.length - 1]; - expect(submission?.initialResponse?.messageId).toBe(olderResponse.messageId); + expect(submission?.initialResponse?.messageId).toBe(`${olderResponse.messageId}_`); + expect(submission?.initialResponse).toEqual( + expect.objectContaining({ + sender: olderResponse.sender, + model: olderResponse.model, + iconURL: olderResponse.iconURL, + }), + ); + expect(submission?.isRegenerate).toBe(true); expect((submission?.messages ?? []).map((message) => message.messageId)).toEqual([ + rootUser.messageId, newerResponse.messageId, + olderResponse.messageId, + ]); + expect((submission?.regenerateMessages ?? []).map((message) => message.messageId)).toEqual([ + rootUser.messageId, + newerResponse.messageId, + olderResponse.messageId, + ]); + }); + + it('preserves an exact-ID edited regeneration branch for early-abort rollback', async () => { + const rootUser = buildUserMessage(CONVERSATION_ID, 'root-user'); + const editedResponse = { + messageId: 'edited-response', + parentMessageId: rootUser.messageId, + conversationId: CONVERSATION_ID, + text: 'Original response before the edit', + isCreatedByUser: false, + } as TMessage; + const siblingResponse = { + messageId: 'sibling-response', + parentMessageId: rootUser.messageId, + conversationId: CONVERSATION_ID, + text: 'Unrelated sibling', + isCreatedByUser: false, + } as TMessage; + const observedSubmissions: Array = []; + + mockUseStreamStatus.mockReturnValue({ + isSuccess: true, + isFetching: false, + data: { + active: true, + status: 'running', + streamId: CONVERSATION_ID, + resumeState: { + runSteps: [], + aggregatedContent: [], + responseMessageId: editedResponse.messageId, + isRegenerate: true, + conversationId: CONVERSATION_ID, + userMessage: { + messageId: rootUser.messageId, + parentMessageId: rootUser.parentMessageId, + conversationId: CONVERSATION_ID, + text: rootUser.text, + }, + }, + }, + }); + + renderUseResumeOnLoad({ + messages: [rootUser, siblingResponse, editedResponse], + onSubmission: (currentSubmission) => observedSubmissions.push(currentSubmission), + }); + + await act(async () => { + await Promise.resolve(); + }); + + const submission = observedSubmissions[observedSubmissions.length - 1]; + expect(submission?.isRegenerate).toBe(true); + expect(submission?.initialResponse?.messageId).toBe(editedResponse.messageId); + expect(submission?.messages?.map((message) => message.messageId)).toEqual([ + rootUser.messageId, + siblingResponse.messageId, + ]); + expect(submission?.regenerateMessages?.map((message) => message.messageId)).toEqual([ + rootUser.messageId, + siblingResponse.messageId, + editedResponse.messageId, ]); }); diff --git a/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts b/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts index 6414d30d261..8e6fe87ac07 100644 --- a/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts +++ b/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts @@ -17,7 +17,7 @@ import type { SubagentUpdateEvent, Agents, } from 'librechat-data-provider'; -import { subagentProgressByToolCallId } from '~/store/subagents'; +import { subagentProgressByToolCallId, subagentProgressKey } from '~/store/subagents'; import { resolveAskUserQuestionPart } from '~/utils/approval'; import useStepHandler from '~/hooks/SSE/useStepHandler'; @@ -2990,7 +2990,7 @@ describe('useStepHandler', () => { */ const renderStepHandlerWithReader = (): { result: ReturnType['result']; - getProgress: (toolCallId: string) => unknown; + getProgress: (toolCallId: string, parentMessageId?: string, partIndex?: number) => unknown; } => { /** Composite hook: the step handler under test + a `useRecoilCallback` * reader that shares the same `RecoilRoot` store. Reading via a @@ -3001,8 +3001,18 @@ describe('useStepHandler', () => { const stepHandler = useStepHandler(createHookParams()); const read = useRecoilCallback( ({ snapshot }) => - (toolCallId: string): unknown => - snapshot.getLoadable(subagentProgressByToolCallId(toolCallId)).valueOrThrow(), + ( + toolCallId: string, + parentMessageId: string = 'response-msg-1', + partIndex: number = 0, + ): unknown => + snapshot + .getLoadable( + subagentProgressByToolCallId( + subagentProgressKey(parentMessageId, toolCallId, partIndex), + ), + ) + .valueOrThrow(), [], ); return { ...stepHandler, read }; @@ -3010,8 +3020,11 @@ describe('useStepHandler', () => { { wrapper: RecoilRoot }, ); - const getProgress = (toolCallId: string): unknown => - (hookResult.result.current as any).read(toolCallId); + const getProgress = ( + toolCallId: string, + parentMessageId?: string, + partIndex?: number, + ): unknown => (hookResult.result.current as any).read(toolCallId, parentMessageId, partIndex); return { result: hookResult.result, getProgress }; }; @@ -3054,7 +3067,7 @@ describe('useStepHandler', () => { }; const makeUpdate = (overrides: Partial = {}): SubagentUpdateEvent => ({ - runId: 'parent-run', + runId: 'response-msg-1', subagentRunId: 'child-run-1', subagentType: 'self', subagentAgentId: 'child-1', @@ -3143,7 +3156,7 @@ describe('useStepHandler', () => { }); const first = getProgress('call_old') as { latestLabel?: string }; - const second = getProgress('call_new') as { latestLabel?: string }; + const second = getProgress('call_new', undefined, 1) as { latestLabel?: string }; expect(first.latestLabel).toBe('first'); expect(second.latestLabel).toBe('second'); }); @@ -3336,7 +3349,7 @@ describe('useStepHandler', () => { status: string; latestLabel?: string; }; - const bucketB = getProgress('call_b') as { + const bucketB = getProgress('call_b', undefined, 1) as { subagentRunId: string; status: string; latestLabel?: string; @@ -3355,10 +3368,140 @@ describe('useStepHandler', () => { expect(bucketB.status).toBe('run_step'); }); - it('clearStepMaps preserves subagent atoms so the dialog can be re-opened for auditability', () => { + it('keeps reused provider tool-call IDs isolated across parent messages', () => { + const { result, getProgress } = renderStepHandlerWithReader(); + const firstResponse: TMessage = { + ...createResponseMessage({ messageId: 'response-one' }), + content: [buildSubagentToolCallPart('call_shared')], + }; + const secondResponse: TMessage = { + ...createResponseMessage({ messageId: 'response-two' }), + content: [buildSubagentToolCallPart('call_shared')], + }; + act(() => { + (result.current as any).syncStepMessage(firstResponse); + (result.current as any).syncStepMessage(secondResponse); + (result.current as any).stepHandler( + { + event: StepEvents.ON_SUBAGENT_UPDATE, + data: makeUpdate({ + runId: 'response-one', + subagentRunId: 'child-one', + parentToolCallId: 'call_shared', + label: 'first parent', + }), + }, + createSubmission(), + ); + (result.current as any).stepHandler( + { + event: StepEvents.ON_SUBAGENT_UPDATE, + data: makeUpdate({ + runId: 'response-two', + subagentRunId: 'child-two', + parentToolCallId: 'call_shared', + label: 'second parent', + }), + }, + createSubmission(), + ); + }); + + expect(getProgress('call_shared', 'response-one')).toEqual( + expect.objectContaining({ subagentRunId: 'child-one', latestLabel: 'first parent' }), + ); + expect(getProgress('call_shared', 'response-two')).toEqual( + expect.objectContaining({ subagentRunId: 'child-two', latestLabel: 'second parent' }), + ); + }); + + it('buffers an update for its expected parent instead of claiming another same-ID call', () => { + const { result, getProgress } = renderStepHandlerWithReader(); + const firstResponse: TMessage = { + ...createResponseMessage({ messageId: 'response-one' }), + content: [buildSubagentToolCallPart('call_shared')], + }; + act(() => { + (result.current as any).syncStepMessage(firstResponse); + (result.current as any).stepHandler( + { + event: StepEvents.ON_SUBAGENT_UPDATE, + data: makeUpdate({ + runId: 'response-two', + subagentRunId: 'child-two', + parentToolCallId: 'call_shared', + phase: 'stop', + label: 'finished before parent two arrived', + }), + }, + createSubmission(), + ); + }); + + expect(getProgress('call_shared', 'response-one')).toBeNull(); + + const secondResponse: TMessage = { + ...createResponseMessage({ messageId: 'response-two' }), + content: [buildSubagentToolCallPart('call_shared')], + }; + act(() => { + (result.current as any).syncStepMessage(secondResponse); + }); + + expect(getProgress('call_shared', 'response-one')).toBeNull(); + expect(getProgress('call_shared', 'response-two')).toEqual( + expect.objectContaining({ + subagentRunId: 'child-two', + status: 'stop', + latestLabel: 'finished before parent two arrived', + }), + ); + }); + + it('keeps repeated provider tool-call IDs isolated by content-part occurrence', () => { + const { result, getProgress } = renderStepHandlerWithReader(); + const { submission } = seedResponseWithSubagentToolCalls(result, [ + 'call_shared', + 'call_shared', + ]); + + act(() => { + (result.current as any).stepHandler( + { + event: StepEvents.ON_SUBAGENT_UPDATE, + data: makeUpdate({ + subagentRunId: 'child-one', + parentToolCallId: 'call_shared', + label: 'first occurrence', + }), + }, + submission, + ); + (result.current as any).stepHandler( + { + event: StepEvents.ON_SUBAGENT_UPDATE, + data: makeUpdate({ + subagentRunId: 'child-two', + parentToolCallId: 'call_shared', + label: 'second occurrence', + }), + }, + submission, + ); + }); + + expect(getProgress('call_shared', 'response-msg-1', 0)).toEqual( + expect.objectContaining({ subagentRunId: 'child-one', latestLabel: 'first occurrence' }), + ); + expect(getProgress('call_shared', 'response-msg-1', 1)).toEqual( + expect.objectContaining({ subagentRunId: 'child-two', latestLabel: 'second occurrence' }), + ); + }); + + it('clearStepMaps preserves subagent atoms so the panel can be re-opened for auditability', () => { /** * Intentionally the inverse of the earlier behavior: the collapsed - * `SubagentCall` ticker and its dialog must stay readable after the + * `SubagentCall` ticker and its panel must stay readable after the * stream ends. Wiping the atoms on `clearStepMaps` would leave a * completed subagent tool call with no content to display, forcing * the fallback "raw tool output" branch and losing interleaved tool diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 4c5b6b0e5f5..570eafbcb57 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -185,6 +185,35 @@ export const getExistingConversationAbortMessages = ({ return [...sourceMessages]; }; +export const mergeErrorMessages = ({ + messages, + regenerateMessages, + userMessage, + errorMessage, + isRegenerate = false, +}: Pick & { + errorMessage: TMessage; +}): TMessage[] => { + if (isRegenerate) { + const finalMessages: TMessage[] = []; + let replaced = false; + for (const message of regenerateMessages ?? messages) { + if (message.messageId === errorMessage.messageId) { + finalMessages.push(errorMessage); + replaced = true; + } else { + finalMessages.push(message); + } + } + if (!replaced) { + finalMessages.push(errorMessage); + } + return finalMessages; + } + + return [...messages, userMessage, errorMessage]; +}; + export type EventHandlerParams = { isAddedRequest?: boolean; runIndex?: number; @@ -945,14 +974,14 @@ export default function useEventHandlers({ const errorHandler = useCallback( ({ data, submission }: { data?: TResData; submission: EventSubmission }) => { - const { messages, userMessage, initialResponse } = submission; + const { userMessage, initialResponse } = submission; setCompleted((prev) => new Set(prev.add(initialResponse.messageId))); const conversationId = userMessage.conversationId ?? submission.conversation?.conversationId ?? ''; const setErrorMessages = (convoId: string, errorMessage: TMessage) => { - const finalMessages: TMessage[] = [...messages, userMessage, errorMessage]; + const finalMessages = mergeErrorMessages({ ...submission, errorMessage }); setMessages(finalMessages); queryClient.setQueryData([QueryKeys.messages, convoId], finalMessages); }; diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index 67ed8957181..613a86e48cd 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -583,18 +583,109 @@ const buildResumeEventSubmission = ( } as EventSubmission; }; +type ResumeMessageIndexes = { + userIndex: number; + responseIndex: number; + preliminaryUserIndex: number; + preliminaryResponseIndex: number; +}; + +const getResumeMessageIndexes = ( + messages: TMessage[], + userMessageId: string, + responseMessageId: string, + preliminaryUserMessageId?: string, + preliminaryResponseMessageId?: string, +): ResumeMessageIndexes => { + let userIndex = -1; + let responseIndex = -1; + let preliminaryUserIndex = -1; + let preliminaryResponseIndex = -1; + const hasPreliminaryResponse = preliminaryResponseMessageId?.endsWith('_') === true; + const eligiblePreliminaryUserId = + hasPreliminaryResponse && preliminaryUserMessageId && preliminaryUserMessageId !== userMessageId + ? preliminaryUserMessageId + : undefined; + const eligiblePreliminaryResponseId = + hasPreliminaryResponse && preliminaryResponseMessageId !== responseMessageId + ? preliminaryResponseMessageId + : undefined; + + for (let index = 0; index < messages.length; index++) { + const messageId = messages[index]?.messageId; + if (userIndex < 0 && messageId === userMessageId) { + userIndex = index; + } + if (responseIndex < 0 && messageId === responseMessageId) { + responseIndex = index; + } + if ( + preliminaryUserIndex < 0 && + eligiblePreliminaryUserId && + messageId === eligiblePreliminaryUserId + ) { + preliminaryUserIndex = index; + } + if ( + preliminaryResponseIndex < 0 && + eligiblePreliminaryResponseId && + messageId === eligiblePreliminaryResponseId + ) { + preliminaryResponseIndex = index; + } + } + + return { userIndex, responseIndex, preliminaryUserIndex, preliminaryResponseIndex }; +}; + const mergeResumeMessages = ( messages: TMessage[], userMessage: TMessage, responseMessage: TMessage, + indexes: ResumeMessageIndexes, ): TMessage[] => { const nextMessages = [...messages]; - const userIndex = nextMessages.findIndex( - (message) => message.messageId === userMessage.messageId, - ); - const responseIndex = nextMessages.findIndex( - (message) => message.messageId === responseMessage.messageId, - ); + let { userIndex, responseIndex, preliminaryResponseIndex } = indexes; + const { preliminaryUserIndex } = indexes; + + if (preliminaryUserIndex >= 0) { + if (userIndex >= 0) { + nextMessages.splice(preliminaryUserIndex, 1); + if (userIndex > preliminaryUserIndex) { + userIndex -= 1; + } + if (responseIndex > preliminaryUserIndex) { + responseIndex -= 1; + } + if (preliminaryResponseIndex > preliminaryUserIndex) { + preliminaryResponseIndex -= 1; + } + } else { + nextMessages[preliminaryUserIndex] = { + ...nextMessages[preliminaryUserIndex], + ...userMessage, + }; + userIndex = preliminaryUserIndex; + } + } + + if (preliminaryResponseIndex >= 0) { + if (responseIndex >= 0) { + nextMessages.splice(preliminaryResponseIndex, 1); + if (userIndex > preliminaryResponseIndex) { + userIndex -= 1; + } + if (responseIndex > preliminaryResponseIndex) { + responseIndex -= 1; + } + } else { + nextMessages[preliminaryResponseIndex] = { + ...nextMessages[preliminaryResponseIndex], + ...responseMessage, + }; + responseIndex = preliminaryResponseIndex; + } + } if (userIndex >= 0) { nextMessages[userIndex] = { ...nextMessages[userIndex], ...userMessage }; @@ -609,9 +700,7 @@ const mergeResumeMessages = ( } if (userIndex >= 0) { - const insertAt = userIndex + 1; - nextMessages.splice(insertAt, 0, responseMessage); - return nextMessages; + return [...nextMessages, responseMessage]; } if (responseIndex >= 0) { @@ -1848,6 +1937,16 @@ export default function useResumableSSE( const runId = v4(); setActiveRunId(runId); + /** Keep the current run's preliminary id long enough to replace that optimistic + * row in place if this snapshot assigns its durable response id. */ + const preliminaryResponseMessageId = currentSubmission.initialResponse?.messageId; + const currentUserMessageId = currentSubmission.userMessage?.messageId; + const preliminaryUserMessageId = + currentUserMessageId && + (currentSubmission.initialResponse?.parentMessageId === currentUserMessageId || + preliminaryResponseMessageId === `${currentUserMessageId}_`) + ? currentUserMessageId + : undefined; const resumeSubmission = buildResumeEventSubmission( currentSubmission, userMessage, @@ -1893,28 +1992,23 @@ export default function useResumableSSE( if (data.resumeState?.aggregatedContent && userMessage?.messageId) { const messages = getMessages() ?? []; const userMsgId = userMessage.messageId; - const serverResponseId = data.resumeState.responseMessageId; const hasResumedContent = data.resumeState.aggregatedContent.length > 0; - - let responseIdx = -1; - /** Only an id match proves the row belongs to THIS generation; the parent-based - * fallback below can land on a prior sibling (e.g. the answer being regenerated). */ - let matchedByResponseId = false; - if (serverResponseId) { - responseIdx = messages.findIndex((m) => m.messageId === serverResponseId); - matchedByResponseId = responseIdx >= 0; - } - if (responseIdx < 0) { - responseIdx = messages.findIndex( - (m) => - !m.isCreatedByUser && - (m.messageId === `${userMsgId}_` || m.parentMessageId === userMsgId), - ); - } + const responseId = resumeSubmission.initialResponse.messageId; + const messageIndexes = getResumeMessageIndexes( + messages, + userMsgId, + responseId, + preliminaryUserMessageId, + preliminaryResponseMessageId, + ); + const responseIdx = + messageIndexes.responseIndex >= 0 + ? messageIndexes.responseIndex + : messageIndexes.preliminaryResponseIndex; logger.log('ResumableSSE', 'SYNC update', { userMsgId, - serverResponseId, + responseId, responseIdx, foundMessageId: responseIdx >= 0 ? messages[responseIdx]?.messageId : null, messagesCount: messages.length, @@ -1924,15 +2018,11 @@ export default function useResumableSSE( if (responseIdx >= 0) { const oldContent = messages[responseIdx]?.content; /** An EMPTY resume snapshot is not authoritative over content we already loaded - * for the SAME response: assigning it would erase that content and leave a bare - * cursor. Restricted to an id match — preserving a fallback-matched row would - * make a regenerated run append to the answer it is replacing — and to a row - * that actually HAS parts, so the array is never swapped for `undefined`. */ + * for the SAME generation-owned response: assigning it would erase that content + * and leave a bare cursor. Require an existing content array so it is never + * swapped for `undefined`. */ const preserveLoadedContent = - !hasResumedContent && - matchedByResponseId && - Array.isArray(oldContent) && - oldContent.length > 0; + !hasResumedContent && Array.isArray(oldContent) && oldContent.length > 0; /** * Replacing the response with `aggregatedContent` drops the * prefix an edited resubmission had retained: the snapshot is @@ -1947,21 +2037,32 @@ export default function useResumableSSE( editPrefixClearedRef.current = true; } const responseMessage = { + ...resumeSubmission.initialResponse, ...messages[responseIdx], + messageId: responseId, + parentMessageId: userMsgId, content: preserveLoadedContent ? oldContent : data.resumeState.aggregatedContent, + sender: messages[responseIdx]?.sender ?? resumeSubmission.initialResponse.sender, iconURL: preferDefinedString( messages[responseIdx]?.iconURL, - data.resumeState.iconURL, + resumeSubmission.initialResponse.iconURL ?? data.resumeState.iconURL, + ), + model: preferDefinedString( + messages[responseIdx]?.model, + resumeSubmission.initialResponse.model ?? data.resumeState.model, ), - model: preferDefinedString(messages[responseIdx]?.model, data.resumeState.model), } as TMessage; - const updated = mergeResumeMessages(messages, userMessage, responseMessage); + const updated = mergeResumeMessages( + messages, + userMessage, + responseMessage, + messageIndexes, + ); logger.log('ResumableSSE', 'SYNC updating message', { messageId: responseMessage.messageId, oldContentLength: Array.isArray(oldContent) ? oldContent.length : 0, newContentLength: data.resumeState.aggregatedContent?.length, preservedExistingContent: preserveLoadedContent, - matchedByResponseId, }); setMessages(updated); resetContentHandler(); @@ -1975,18 +2076,14 @@ export default function useResumableSSE( * only in the matched branch left this path adding an offset * to indices that were already absolute. */ editPrefixClearedRef.current = true; - const responseId = serverResponseId ?? `${userMsgId}_`; const newMessage = { + ...resumeSubmission.initialResponse, messageId: responseId, parentMessageId: userMsgId, - conversationId: currentSubmission.conversation?.conversationId ?? '', - text: '', content: data.resumeState.aggregatedContent, isCreatedByUser: false, - iconURL: data.resumeState.iconURL, - model: data.resumeState.model, } as TMessage; - setMessages(mergeResumeMessages(messages, userMessage, newMessage)); + setMessages(mergeResumeMessages(messages, userMessage, newMessage, messageIndexes)); resetContentHandler(); syncStepMessage(newMessage); } diff --git a/client/src/hooks/SSE/useResumeOnLoad.ts b/client/src/hooks/SSE/useResumeOnLoad.ts index 9ff950cc735..f0b0f7653fe 100644 --- a/client/src/hooks/SSE/useResumeOnLoad.ts +++ b/client/src/hooks/SSE/useResumeOnLoad.ts @@ -128,15 +128,27 @@ function buildSubmissionFromResumeState( (m) => m.isCreatedByUser && m.messageId === userMessageData?.messageId, ); - // Try to find existing response message in the messages array (from database). - // Regeneration can expose the in-flight placeholder id with trailing underscores - // while the persisted sibling uses the unpadded id. Prefer both exact identities - // before falling back to the shared parent, where several branch siblings can match. + // A trailing underscore distinguishes an in-flight regeneration from the persisted + // response it replaces. Only the exact response id proves generation ownership. + const existingResponseMessage = messages.find( + (m) => !m.isCreatedByUser && m.messageId === responseMessageId, + ); + // The persisted row may seed display metadata, but never identity or deduplication. const unpaddedResponseMessageId = responseMessageId.replace(/_+$/, ''); - const existingResponseMessage = - messages.find((m) => !m.isCreatedByUser && m.messageId === responseMessageId) ?? - messages.find((m) => !m.isCreatedByUser && m.messageId === unpaddedResponseMessageId) ?? - messages.find((m) => !m.isCreatedByUser && m.parentMessageId === userMessageData?.messageId); + const persistedRegenerationResponse = + unpaddedResponseMessageId !== responseMessageId + ? messages.find((m) => !m.isCreatedByUser && m.messageId === unpaddedResponseMessageId) + : undefined; + const responseMetadataMessage = existingResponseMessage ?? persistedRegenerationResponse; + const isRegenerateResume = + resumeState.isRegenerate === true || persistedRegenerationResponse != null; + let regenerateMessages: TMessage[] | undefined; + if (isRegenerateResume) { + regenerateMessages = + unpaddedResponseMessageId === responseMessageId + ? [...messages] + : messages.filter((message) => message.messageId !== responseMessageId); + } // Create or use existing user message const userMessage: TMessage = @@ -169,9 +181,9 @@ function buildSubmissionFromResumeState( content: (resumeState.aggregatedContent as TMessage['content']) ?? [], isCreatedByUser: false, role: 'assistant', - sender: existingResponseMessage?.sender ?? resumeState.sender, - model: preferDefinedString(existingResponseMessage?.model, resumeState.model), - iconURL: preferDefinedString(existingResponseMessage?.iconURL, resumeState.iconURL), + sender: responseMetadataMessage?.sender ?? resumeState.sender, + model: preferDefinedString(responseMetadataMessage?.model, resumeState.model), + iconURL: preferDefinedString(responseMetadataMessage?.iconURL, resumeState.iconURL), } as TMessage; // Re-paused turn: seed the approval / ask-user controls straight onto the @@ -186,17 +198,13 @@ function buildSubmissionFromResumeState( endpoint: null, } as TConversation; - // On reload, `messages` is the full DB array, which already holds the paused user - // row and the partial (unfinished) assistant row under the same ids that - // `userMessage` / `initialResponse` (and the resume final event's request/response - // messages) re-supply. Strip them so createdHandler/finalHandler — which build - // `[...messages, requestMessage, responseMessage]` — don't append a duplicate pair. - const pausedResponseIdUnpadded = initialResponse.messageId.replace(/_+$/, ''); + // Non-regenerate resumes strip the persisted request/response pair before handlers + // re-supply it. A regeneration keeps the original branch for early-abort rollback; + // explicit resume metadata covers edited regenerations that reuse the exact response id. const dedupedMessages = messages.filter( (m) => - m.messageId !== userMessage.messageId && m.messageId !== initialResponse.messageId && - m.messageId !== pausedResponseIdUnpadded, + (isRegenerateResume || m.messageId !== userMessage.messageId), ); return { @@ -204,7 +212,8 @@ function buildSubmissionFromResumeState( userMessage, initialResponse, conversation, - isRegenerate: false, + isRegenerate: isRegenerateResume, + ...(regenerateMessages && { regenerateMessages }), isTemporary: false, endpointOption: {}, // Signal to useResumableSSE to subscribe to existing stream instead of starting new diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts index 9344b68ac6c..92728a7e935 100644 --- a/client/src/hooks/SSE/useStepHandler.ts +++ b/client/src/hooks/SSE/useStepHandler.ts @@ -28,8 +28,12 @@ import { initSubagentAggregatorState, initSubagentTickerState, } from '~/utils/subagentContent'; +import { + subagentProgressByToolCallId, + subagentProgressKey, + sandboxStartingByToolCallId, +} from '~/store'; import { isAskUserQuestionPart, isAnsweredAskUserQuestionPart } from '~/utils/approval'; -import { subagentProgressByToolCallId, sandboxStartingByToolCallId } from '~/store'; import { MESSAGE_UPDATE_INTERVAL } from '~/common'; type TUseStepHandler = { @@ -163,22 +167,22 @@ export default function useStepHandler({ const pendingDeltaFlushIds = useRef(new Set()); const pendingDeltaFlushRef = useRef<(() => void) | null>(null); /** - * Maps `SubagentUpdateEvent.subagentRunId` → parent `tool_call_id`. - * Preferred source is `payload.parentToolCallId` (threaded through by the - * SDK from `ToolRunnableConfig.toolCall.id`, deterministic). If a host - * runs an older SDK that doesn't emit it, we fall back to a temporal - * claim: the OLDEST unclaimed `subagent` tool call in the active message. - * Forward (oldest-first) iteration matches the order tool calls are - * created in, so concurrent spawns map in creation order. + * Maps `SubagentUpdateEvent.subagentRunId` → one concrete parent content-part + * occurrence. `payload.parentToolCallId` narrows the candidates when present, + * but provider IDs are not unique enough to be the atom identity: they may be + * reused across messages or even within one message. Forward (oldest-first) + * claiming preserves creation order for both modern and legacy envelopes. */ - const subagentRunToToolCallId = useRef(new Map()); - const claimedSubagentToolCallIds = useRef(new Set()); + const subagentRunToInvocationKey = useRef(new Map()); + const claimedSubagentInvocationKeys = useRef(new Set()); /** * Buffers for envelopes that arrive before their `subagent` tool call is * reflected in `messageMap`. Keyed by `subagentRunId`. Once a tool call is * claimed we drain the buffer into the Recoil atom in arrival order. */ - const pendingSubagentBuffer = useRef(new Map()); + const pendingSubagentBuffer = useRef( + new Map(), + ); /** * Tracked atom keys so `clearStepMaps` can reset them. Without this, each * subagent invocation leaks an `events: SubagentUpdateEvent[]` array in the @@ -201,23 +205,23 @@ export default function useStepHandler({ * memory past what the structural output requires. */ /** - * Attempts to resolve the parent `tool_call_id` for a subagent run, using - * the SDK-provided `parentToolCallId` first and falling back to an - * oldest-unclaimed temporal claim. + * Resolves a subagent run to an occurrence-scoped parent invocation key. */ - const resolveSubagentToolCallId = useCallback( - (payload: SubagentUpdateEvent): string | undefined => { - const cached = subagentRunToToolCallId.current.get(payload.subagentRunId); + const resolveSubagentInvocationKey = useCallback( + (payload: SubagentUpdateEvent, parentMessageId: string): string | undefined => { + const cached = subagentRunToInvocationKey.current.get(payload.subagentRunId); if (cached != null) return cached; - - if (payload.parentToolCallId) { - subagentRunToToolCallId.current.set(payload.subagentRunId, payload.parentToolCallId); - claimedSubagentToolCallIds.current.add(payload.parentToolCallId); - return payload.parentToolCallId; - } - - // Fallback — oldest unclaimed subagent tool call wins. - for (const message of messageMap.current.values()) { + if (parentMessageId === '') return undefined; + + // Claim one concrete content-part occurrence. Providers can repeat a + // tool_call ID even within one assistant message, so raw IDs alone are + // not sufficient identity for either the card or its live progress. + const preferred = messageMap.current.get(parentMessageId); + // `runId` gives us the expected parent message. If that message has not + // arrived yet, buffer instead of claiming a same-ID call from another + // parallel response; the mapping is permanent once claimed. + if (preferred == null) return undefined; + for (const [messageId, message] of [[parentMessageId, preferred] as const]) { const content = message.content; if (!Array.isArray(content)) continue; for (let i = 0; i < content.length; i++) { @@ -229,11 +233,13 @@ export default function useStepHandler({ if ( tc?.name === Constants.SUBAGENT && tc.id && - !claimedSubagentToolCallIds.current.has(tc.id) + (payload.parentToolCallId == null || tc.id === payload.parentToolCallId) && + !claimedSubagentInvocationKeys.current.has(subagentProgressKey(messageId, tc.id, i)) ) { - subagentRunToToolCallId.current.set(payload.subagentRunId, tc.id); - claimedSubagentToolCallIds.current.add(tc.id); - return tc.id; + const invocationKey = subagentProgressKey(messageId, tc.id, i); + subagentRunToInvocationKey.current.set(payload.subagentRunId, invocationKey); + claimedSubagentInvocationKeys.current.add(invocationKey); + return invocationKey; } } } @@ -251,24 +257,27 @@ export default function useStepHandler({ */ const applySubagentUpdate = useRecoilCallback( ({ set }) => - (payload: SubagentUpdateEvent): void => { - const toolCallId = resolveSubagentToolCallId(payload); + (payload: SubagentUpdateEvent, parentMessageId: string): void => { + const invocationKey = resolveSubagentInvocationKey(payload, parentMessageId); - if (!toolCallId) { - const queue = pendingSubagentBuffer.current.get(payload.subagentRunId) ?? []; - queue.push(payload); - pendingSubagentBuffer.current.set(payload.subagentRunId, queue); + if (!invocationKey) { + const pending = pendingSubagentBuffer.current.get(payload.subagentRunId) ?? { + parentMessageId, + events: [], + }; + pending.events.push(payload); + pendingSubagentBuffer.current.set(payload.subagentRunId, pending); return; } - const buffered = pendingSubagentBuffer.current.get(payload.subagentRunId); - if (buffered && buffered.length > 0) { + const pending = pendingSubagentBuffer.current.get(payload.subagentRunId); + if (pending && pending.events.length > 0) { pendingSubagentBuffer.current.delete(payload.subagentRunId); } - const toApply = buffered ? [...buffered, payload] : [payload]; + const toApply = pending ? [...pending.events, payload] : [payload]; - knownSubagentAtomKeys.current.add(toolCallId); - set(subagentProgressByToolCallId(toolCallId), (prev) => { + knownSubagentAtomKeys.current.add(invocationKey); + set(subagentProgressByToolCallId(invocationKey), (prev) => { /** Fold the batch into both aggregators. Pure functions — they * return a new reference only when something actually changed, * so React bails out of unnecessary re-renders downstream. */ @@ -297,25 +306,25 @@ export default function useStepHandler({ }; }); }, - [resolveSubagentToolCallId], + [resolveSubagentInvocationKey], ); /** * Resets all accumulated subagent Recoil state. Kept for conversation- * switch cleanup (see top-level hook usage) but NOT called from - * `clearStepMaps` — the collapsed SubagentCall ticker and its dialog + * `clearStepMaps` — the collapsed SubagentCall ticker and its panel * read from these atoms to render the child's content parts, and we * want that history to remain visible after the stream ends so the - * user can reopen the dialog for auditability. The atoms are bounded - * per-call (200-event cap) and per-conversation (one atom per + * user can reopen the panel for auditability. The atoms are bounded + * by aggregated structure and per-conversation (one atom per * subagent spawn), so growth is proportional to messages — the same * growth profile as the rest of the conversation state. */ const resetSubagentAtoms = useRecoilCallback( ({ reset }) => (): void => { - for (const toolCallId of knownSubagentAtomKeys.current) { - reset(subagentProgressByToolCallId(toolCallId)); + for (const invocationKey of knownSubagentAtomKeys.current) { + reset(subagentProgressByToolCallId(invocationKey)); } knownSubagentAtomKeys.current.clear(); }, @@ -1270,7 +1279,11 @@ export default function useStepHandler({ } else if (stepEvent.event === StepEvents.ON_SANDBOX_STARTING) { setSandboxStarting(stepEvent.data.tool_call_id); } else if (stepEvent.event === StepEvents.ON_SUBAGENT_UPDATE) { - applySubagentUpdate(stepEvent.data); + let responseMessageId = stepEvent.data.runId; + if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) { + responseMessageId = submission?.initialResponse?.messageId ?? ''; + } + applySubagentUpdate(stepEvent.data, responseMessageId); } else if (stepEvent.event === StepEvents.ON_SUMMARIZE_START) { announcePolite({ message: 'summarize_started', isStatus: true }); } else if (stepEvent.event === StepEvents.ON_SUMMARIZE_DELTA) { @@ -1421,8 +1434,8 @@ export default function useStepHandler({ messageMap.current.clear(); stepMap.current.clear(); pendingDeltaBuffer.current.clear(); - subagentRunToToolCallId.current.clear(); - claimedSubagentToolCallIds.current.clear(); + subagentRunToInvocationKey.current.clear(); + claimedSubagentInvocationKeys.current.clear(); pendingSubagentBuffer.current.clear(); /** Unlike subagent atoms below, sandbox-starting flags are transient * status with no audit value — reset them at this boundary so an @@ -1444,11 +1457,22 @@ export default function useStepHandler({ * Call this after receiving sync event to ensure subsequent deltas * build on the synced content, not stale content. */ - const syncStepMessage = useCallback((message: TMessage) => { - if (message?.messageId) { + const syncStepMessage = useCallback( + (message: TMessage) => { + if (!message?.messageId) return; messageMap.current.set(message.messageId, { ...message }); - } - }, []); + const ready = [...pendingSubagentBuffer.current.entries()].filter( + ([, pending]) => pending.parentMessageId === message.messageId, + ); + for (const [subagentRunId, pending] of ready) { + pendingSubagentBuffer.current.delete(subagentRunId); + for (const event of pending.events) { + applySubagentUpdate(event, message.messageId); + } + } + }, + [applySubagentUpdate], + ); return { stepHandler, diff --git a/client/src/hooks/__tests__/useCatalogWarmup.spec.tsx b/client/src/hooks/__tests__/useCatalogWarmup.spec.tsx new file mode 100644 index 00000000000..2ed7982684a --- /dev/null +++ b/client/src/hooks/__tests__/useCatalogWarmup.spec.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { render, act, cleanup } from '@testing-library/react'; +import type { CatalogId } from '../useCatalogWarmup'; +import { + useCatalogWarmup, + useCatalogReady, + activateCatalog, + resetCatalogWarmup, +} from '../useCatalogWarmup'; + +const CATALOG_IDS: CatalogId[] = ['prompts', 'mcpServers', 'mcpTools']; + +let readyState: Record; + +function Harness({ authenticated }: { authenticated: boolean }) { + useCatalogWarmup(authenticated); + readyState = { + prompts: useCatalogReady('prompts'), + mcpServers: useCatalogReady('mcpServers'), + mcpTools: useCatalogReady('mcpTools'), + }; + return null; +} + +const idleCallbacks: Array<() => void> = []; + +function installIdleCallback() { + Object.defineProperty(window, 'requestIdleCallback', { + configurable: true, + writable: true, + value: (callback: () => void) => { + idleCallbacks.push(callback); + return idleCallbacks.length; + }, + }); +} + +function flushIdle() { + act(() => { + idleCallbacks.splice(0).forEach((callback) => callback()); + }); +} + +describe('useCatalogWarmup', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.spyOn(Math, 'random').mockReturnValue(0); + idleCallbacks.length = 0; + installIdleCallback(); + resetCatalogWarmup(); + }); + + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('keeps every catalog gated until idle fires and each stagger elapses', () => { + render(); + expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false }); + + flushIdle(); + expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false }); + + act(() => { + jest.advanceTimersByTime(0); + }); + expect(readyState.prompts).toBe(true); + expect(readyState.mcpServers).toBe(false); + + act(() => { + jest.advanceTimersByTime(250); + }); + expect(readyState.mcpServers).toBe(true); + expect(readyState.mcpTools).toBe(false); + + act(() => { + jest.advanceTimersByTime(500); + }); + expect(readyState.mcpTools).toBe(true); + }); + + it('does not schedule warmup while unauthenticated', () => { + render(); + + act(() => { + jest.runAllTimers(); + }); + expect(idleCallbacks.length).toBe(0); + expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false }); + }); + + it('releases a catalog immediately on activation', () => { + render(); + flushIdle(); + + act(() => { + activateCatalog('mcpTools'); + }); + expect(readyState.mcpTools).toBe(true); + expect(readyState.prompts).toBe(false); + + /** The superseded stagger timer must not flip anything back */ + act(() => { + jest.runAllTimers(); + }); + CATALOG_IDS.forEach((id) => { + expect(readyState[id]).toBe(true); + }); + }); + + it('falls back to a timeout when requestIdleCallback is unavailable', () => { + Object.defineProperty(window, 'requestIdleCallback', { + configurable: true, + writable: true, + value: undefined, + }); + render(); + + act(() => { + jest.advanceTimersByTime(199); + }); + expect(readyState.prompts).toBe(false); + + act(() => { + jest.advanceTimersByTime(1); + }); + /** The stagger timer is scheduled from inside the fallback timeout */ + act(() => { + jest.runOnlyPendingTimers(); + }); + expect(readyState.prompts).toBe(true); + }); + + it('resets to fully gated state', () => { + render(); + flushIdle(); + act(() => { + jest.runAllTimers(); + }); + + act(() => { + resetCatalogWarmup(); + }); + expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false }); + }); + + it('re-arms the schedule after logout so the next session warms again', () => { + const view = render(); + flushIdle(); + act(() => { + jest.runAllTimers(); + }); + CATALOG_IDS.forEach((id) => expect(readyState[id]).toBe(true)); + + view.rerender(); + expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false }); + + view.rerender(); + flushIdle(); + act(() => { + jest.advanceTimersByTime(0); + }); + expect(readyState.prompts).toBe(true); + expect(readyState.mcpTools).toBe(false); + }); + + it('voids idle callbacks scheduled before a logout', () => { + const view = render(); + /** Idle has not fired yet when the user logs out */ + view.rerender(); + + flushIdle(); + act(() => { + jest.runAllTimers(); + }); + expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false }); + + /** The next session schedules and warms normally */ + view.rerender(); + flushIdle(); + act(() => { + jest.advanceTimersByTime(0); + }); + expect(readyState.prompts).toBe(true); + }); + + it('resets on unmount, for logouts that tear Root down without a false render', () => { + const view = render(); + flushIdle(); + act(() => { + jest.runAllTimers(); + }); + CATALOG_IDS.forEach((id) => expect(readyState[id]).toBe(true)); + + view.unmount(); + idleCallbacks.length = 0; + + render(); + expect(readyState).toEqual({ prompts: false, mcpServers: false, mcpTools: false }); + flushIdle(); + act(() => { + jest.advanceTimersByTime(0); + }); + expect(readyState.prompts).toBe(true); + }); +}); diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts index f339fc5f819..fe7868ebd34 100644 --- a/client/src/hooks/index.ts +++ b/client/src/hooks/index.ts @@ -42,3 +42,10 @@ export { default as useGenerationsByLatest } from './useGenerationsByLatest'; export { default as useLocalizedConfig } from './useLocalizedConfig'; export { default as useResourcePermissions } from './useResourcePermissions'; export { useRoleSelector } from './useRoleSelector'; +export { + useCatalogWarmup, + useCatalogReady, + activateCatalog, + resetCatalogWarmup, +} from './useCatalogWarmup'; +export type { CatalogId } from './useCatalogWarmup'; diff --git a/client/src/hooks/useCatalogWarmup.ts b/client/src/hooks/useCatalogWarmup.ts new file mode 100644 index 00000000000..27a93d5854f --- /dev/null +++ b/client/src/hooks/useCatalogWarmup.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useSyncExternalStore } from 'react'; + +/** + * Feature catalogs (prompts, MCP servers/tools) are not needed to render the + * initial chat UI, so their queries stay disabled until this store releases + * them: after first paint, on browser idle, staggered so the requests never + * land as one burst. Panels that need a catalog sooner call `activateCatalog` + * and their own loading states cover the wait. + */ +export type CatalogId = 'prompts' | 'mcpServers' | 'mcpTools'; + +/** Upper bound on how long warmup may wait behind a busy main thread. */ +const IDLE_TIMEOUT_MS = 2000; +/** Browsers without `requestIdleCallback` get a short fixed delay instead. */ +const IDLE_FALLBACK_MS = 200; +/** Spacing between catalogs, smaller and more commonly used first. */ +const STAGGER_MS: Record = { + prompts: 0, + mcpServers: 250, + mcpTools: 750, +}; +/** Random jitter so a fleet of users loading at once does not warm in lockstep. */ +const MAX_JITTER_MS = 500; + +const ready: Record = { + prompts: false, + mcpServers: false, + mcpTools: false, +}; +const pendingTimers = new Map>(); +const listeners = new Set<() => void>(); +let scheduled = false; +/** Bumped on reset: idle callbacks and their timers capture the value at + * scheduling time and no-op after a reset, so a logout can never leave a + * stale callback releasing catalogs into the next session. */ +let generation = 0; + +function emitChange() { + listeners.forEach((listener) => listener()); +} + +function markReady(id: CatalogId) { + const timer = pendingTimers.get(id); + if (timer) { + clearTimeout(timer); + pendingTimers.delete(id); + } + if (ready[id]) { + return; + } + ready[id] = true; + emitChange(); +} + +/** Releases a catalog immediately, for panels opened before warmup reaches it. */ +export function activateCatalog(id: CatalogId) { + markReady(id); +} + +function scheduleIdle(callback: () => void) { + const scheduledGeneration = generation; + const runIfCurrent = () => { + if (scheduledGeneration === generation) { + callback(); + } + }; + if (typeof window.requestIdleCallback === 'function') { + window.requestIdleCallback(runIfCurrent, { timeout: IDLE_TIMEOUT_MS }); + return; + } + setTimeout(runIfCurrent, IDLE_FALLBACK_MS); +} + +/** + * Starts the one-time warmup schedule. Mounted from Root once the user is + * authenticated; every catalog consumer below Root reads the same store. + * Logout is SPA navigation (no reload) and can unmount Root in the same + * render that flips `isAuthenticated`, so both the unauthenticated branch + * and unmount cleanup reset the schedule for the next session. + */ +export function useCatalogWarmup(isAuthenticated: boolean) { + useEffect(() => { + if (!isAuthenticated) { + resetCatalogWarmup(); + return; + } + if (scheduled) { + return; + } + scheduled = true; + (Object.keys(STAGGER_MS) as CatalogId[]).forEach((id) => { + scheduleIdle(() => { + pendingTimers.set( + id, + setTimeout(() => markReady(id), STAGGER_MS[id] + Math.random() * MAX_JITTER_MS), + ); + }); + }); + return () => resetCatalogWarmup(); + }, [isAuthenticated]); +} + +export function useCatalogReady(id: CatalogId): boolean { + const subscribe = useCallback((onStoreChange: () => void) => { + listeners.add(onStoreChange); + return () => { + listeners.delete(onStoreChange); + }; + }, []); + const getSnapshot = useCallback(() => ready[id], [id]); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +/** Clears timers and readiness so the next session warms on its own schedule. + * Bumping `generation` also voids idle callbacks still pending from the + * previous schedule, whose handles `scheduleIdle` does not retain. */ +export function resetCatalogWarmup() { + generation++; + scheduled = false; + pendingTimers.forEach((timer) => clearTimeout(timer)); + pendingTimers.clear(); + (Object.keys(ready) as CatalogId[]).forEach((id) => { + ready[id] = false; + }); + emitChange(); +} diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 5ad9bdf4d88..3632505a76c 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -610,6 +610,7 @@ "com_nav_mcp_status_error": "Error", "com_nav_mcp_status_initializing": "Initializing", "com_nav_mcp_status_needs_auth": "Needs Auth", + "com_nav_mcp_status_on_demand": "On-demand", "com_nav_mcp_status_unknown": "Unknown", "com_nav_mcp_vars_update_error": "Error updating MCP custom user variables", "com_nav_mcp_vars_updated": "MCP custom user variables updated successfully.", @@ -2155,19 +2156,15 @@ "com_ui_subagent_activity": "Agent activity", "com_ui_subagent_cancelled": "Cancelled agent", "com_ui_subagent_complete": "Ran agent", - "com_ui_subagent_dialog_description": "Isolated-context child run. Activity and final result below.", "com_ui_subagent_dialog_title": "\"{{0}}\" agent", "com_ui_subagent_dialog_title_self": "Agent", "com_ui_subagent_empty_result": "No text returned.", "com_ui_subagent_errored": "Agent errored", "com_ui_subagent_no_result_yet": "Still running — no final result yet.", - "com_ui_subagent_open_thread": "Open child chat", - "com_ui_subagent_thread_empty": "This agent has not recorded any activity yet.", "com_ui_subagent_thread_history_truncated": "Earlier activity is not shown.", "com_ui_subagent_thread_load_error": "The agent activity could not be loaded.", "com_ui_subagent_thread_message_truncated": "This entry was shortened for display.", "com_ui_subagent_thread_panel": "Child agent activity", - "com_ui_subagent_thread_response": "Agent response", "com_ui_subagent_thread_read_only": "This child thread is view-only here. Its parent agent owns this execution and can continue it with the saved thread history.", "com_ui_subagent_thread_status_cancelled": "Cancelled", "com_ui_subagent_thread_status_completed": "Completed", @@ -2175,7 +2172,6 @@ "com_ui_subagent_thread_status_failed": "Failed", "com_ui_subagent_thread_status_interrupted": "Interrupted", "com_ui_subagent_thread_status_running": "Running", - "com_ui_subagent_thread_task": "Assigned task", "com_ui_subagent_running": "Running agent", "com_ui_subagent_scroll_to_bottom": "Scroll to latest", "com_ui_subagent_ticker_error": "Error", diff --git a/client/src/routes/Root.tsx b/client/src/routes/Root.tsx index 236c88faa5e..65cca1564de 100644 --- a/client/src/routes/Root.tsx +++ b/client/src/routes/Root.tsx @@ -11,6 +11,7 @@ import { useSearchEnabled, useAssistantsMap, useAuthContext, + useCatalogWarmup, useAgentsMap, useFileMap, } from '~/hooks'; @@ -58,6 +59,8 @@ export default function Root() { [setSidebarExpanded], ); const { isAuthenticated, logout } = useAuthContext(); + /** Releases feature-catalog queries after first paint on browser idle. */ + useCatalogWarmup(isAuthenticated); useDrawerSwipe({ paneRef, diff --git a/client/src/store/subagents.ts b/client/src/store/subagents.ts index d665aa6feb2..8f6d93ef8f9 100644 --- a/client/src/store/subagents.ts +++ b/client/src/store/subagents.ts @@ -1,5 +1,9 @@ import { atom, atomFamily } from 'recoil'; -import type { SubagentUpdatePhase } from 'librechat-data-provider'; +import type { + PartMetadata, + SubagentUpdatePhase, + TMessageContentParts, +} from 'librechat-data-provider'; import type { SubagentAggregatorState, SubagentContentPart, @@ -9,10 +13,10 @@ import type { /** * Progress bucket captured per subagent tool call. Populated as * `ON_SUBAGENT_UPDATE` SSE events stream in from the backend. Keyed by the - * parent's `tool_call_id` so the `SubagentCall` renderer can look the bucket - * up from the tool call it's rendering. + * parent invocation so provider-local tool call IDs cannot collide across + * separate assistant messages. * - * Both the dialog content and the ticker are aggregated *incrementally* + * Both the panel content and the ticker are aggregated *incrementally* * into the atom as each envelope arrives — the atom never keeps the raw * event array. A long-running subagent can emit thousands of deltas * without the state growing past what its structural output (N text @@ -40,13 +44,25 @@ export interface SubagentProgress { latestLabel?: string; } -/** One parent-owned durable child selected for the read-only activity panel. */ +/** One child invocation selected for the shared read-only activity panel. */ export type ActiveSubagentPanel = { + host: 'conversation' | 'share'; + shareId?: string; parentConversationId: string; - threadId: string; - taskId: string; + parentMessageId: string; toolCallId: string; + partIndex: number; subagentType: string; + prompt?: string; + legacyOutput?: string | null; + persistedContent?: TMessageContentParts[]; + initialProgress: number; + isSubmitting: boolean; + runStepStatus?: PartMetadata['runStepStatus']; + durable?: { + threadId: string; + taskId: string; + }; }; export const activeSubagentPanel = atom({ @@ -54,7 +70,14 @@ export const activeSubagentPanel = atom({ default: null, }); -/** Progress state keyed by parent tool_call_id. */ +/** Stable identity for one subagent invocation in the parent conversation. */ +export const subagentProgressKey = ( + parentMessageId: string, + toolCallId: string, + partIndex: number, +) => `${parentMessageId}\u0000${toolCallId}\u0000${partIndex}`; + +/** Progress state keyed by one concrete tool-call content-part occurrence. */ export const subagentProgressByToolCallId = atomFamily({ key: 'subagentProgressByToolCallId', default: null, diff --git a/client/src/utils/__tests__/downloadFile.test.ts b/client/src/utils/__tests__/downloadFile.test.ts index ed11c3df856..9d64e961ef2 100644 --- a/client/src/utils/__tests__/downloadFile.test.ts +++ b/client/src/utils/__tests__/downloadFile.test.ts @@ -1,4 +1,12 @@ -import { getCodeBlockFilename, isHttpDownloadTarget, triggerDownload } from '../downloadFile'; +import { FileSources } from 'librechat-data-provider'; +import { + getCodeBlockFilename, + getDownloadFilename, + isHttpDownloadTarget, + registerDownloadFilename, + triggerDownload, + unregisterDownloadFilename, +} from '../downloadFile'; describe('downloadFile utilities', () => { let clickSpy: jest.SpyInstance; @@ -68,6 +76,50 @@ describe('downloadFile utilities', () => { jest.advanceTimersByTime(1000); expect(revokeSpy).toHaveBeenCalledWith('blob:https://app.example.com/download-id'); }); + + it('uses registered response metadata to name blob downloads', () => { + const target = 'blob:https://app.example.com/text-download'; + registerDownloadFilename(target, 'report.pdf.txt'); + + triggerDownload(target, 'report.pdf'); + + expect(appendedLink?.download).toBe('report.pdf.txt'); + }); + + it('keeps registered names available for concurrent blob downloads', () => { + const target = 'blob:https://app.example.com/concurrent-download'; + registerDownloadFilename(target, 'report.pdf.txt'); + + triggerDownload(target, 'report.pdf'); + expect(appendedLink?.download).toBe('report.pdf.txt'); + + triggerDownload(target, 'report.pdf'); + expect(appendedLink?.download).toBe('report.pdf.txt'); + }); + + it('clears registered names when blob URLs are released', () => { + const target = 'blob:https://app.example.com/released-download'; + registerDownloadFilename(target, 'report.pdf.txt'); + unregisterDownloadFilename(target); + + triggerDownload(target, 'report.pdf'); + + expect(appendedLink?.download).toBe('report.pdf'); + }); +}); + +describe('getDownloadFilename', () => { + it('adds a text extension for text-source files', () => { + expect(getDownloadFilename('report.pdf', 'file-1', FileSources.text)).toBe('report.pdf.txt'); + }); + + it('recognizes existing text extensions case-insensitively', () => { + expect(getDownloadFilename('NOTES.TXT', 'file-2', FileSources.text)).toBe('NOTES.TXT'); + }); + + it('preserves filenames for other storage sources', () => { + expect(getDownloadFilename('report.pdf', 'file-3', FileSources.local)).toBe('report.pdf'); + }); }); describe('getCodeBlockFilename', () => { diff --git a/client/src/utils/downloadFile.ts b/client/src/utils/downloadFile.ts index ddccc53a2ed..73e5f99e9de 100644 --- a/client/src/utils/downloadFile.ts +++ b/client/src/utils/downloadFile.ts @@ -1,6 +1,32 @@ +import { FileSources } from 'librechat-data-provider'; + +const blobDownloadFilenames = new Map(); + export const isHttpDownloadTarget = (target?: string | null): boolean => /^https?:\/\//i.test(target ?? ''); +export function getDownloadFilename( + fileName: string, + fileId?: string, + fileSource?: string | null, +): string { + const filename = fileName || fileId || 'download'; + if (fileSource !== FileSources.text || filename.toLowerCase().endsWith('.txt')) { + return filename; + } + return `${filename}.txt`; +} + +export function registerDownloadFilename(target: string, filename: string): void { + if (target.startsWith('blob:')) { + blobDownloadFilenames.set(target, filename); + } +} + +export function unregisterDownloadFilename(target: string): void { + blobDownloadFilenames.delete(target); +} + /** * Maps a fenced-block language hint to a file extension. Used to name * downloads of chat code blocks (`code.`). Only languages whose common @@ -63,11 +89,14 @@ export function triggerDownload(target: string, filename: string): void { const isBlob = target.startsWith('blob:'); const link = document.createElement('a'); link.href = target; - link.setAttribute('download', filename); + link.setAttribute('download', blobDownloadFilenames.get(target) ?? filename); document.body.appendChild(link); link.click(); document.body.removeChild(link); if (isBlob) { - setTimeout(() => URL.revokeObjectURL(target), 1000); + setTimeout(() => { + unregisterDownloadFilename(target); + URL.revokeObjectURL(target); + }, 1000); } } diff --git a/e2e/bombadil/hitl-lifecycle.specification.ts b/e2e/bombadil/hitl-lifecycle.specification.ts index 0c005175597..aa7c90c8984 100644 --- a/e2e/bombadil/hitl-lifecycle.specification.ts +++ b/e2e/bombadil/hitl-lifecycle.specification.ts @@ -22,7 +22,10 @@ const HITL_PROMPT = `E2E_ASK_USER_QUESTION:${HITL_LABEL}`; const HITL_QUESTION = `Which environment should Bombadil use for ${HITL_LABEL}?`; const HITL_OPTION = 'Staging'; const FINAL_REPLY = 'E2E mock reply: pong'; -const COMPLETED_ANSWER = 'You answered: Staging'; +const COMPLETED_ANSWER_LABEL = 'You answered:'; +/** The settled Q&A record: a collapsed tool-call line naming the question, + * over a panel holding the description and the answer. */ +const ASK_RECORD = '[data-testid="ask-user-question-call"]'; let reloadIssued = false; let pausedReloadIssued = false; @@ -79,6 +82,12 @@ function target( return null; } +function visibleCount(state: State, selector: string): number { + return Array.from(state.document.querySelectorAll(selector)).filter( + (element) => visiblePoint(state, element) !== null, + ).length; +} + function visibleTextCount( state: State, selector: string, @@ -110,6 +119,7 @@ function clickOrWait(targetValue: Target | null): Action[] { const ui = extract((state: State) => { const messageElements = Array.from(state.document.querySelectorAll('.message-render')); + const askRecordCount = visibleCount(state, ASK_RECORD); const messageText = messageElements.map((element) => element.textContent ?? '').join('\n'); const modelTrigger = state.document.querySelector('button[aria-label="Select a model"]'); return { @@ -124,14 +134,22 @@ const ui = extract((state: State) => { emailFocused: isFocused(state, '#email'), passwordValue: inputValue(state, '#password'), passwordFocused: isFocused(state, '#password'), - questionCount: visibleTextCount(state, 'p', HITL_QUESTION), + /** Once the pause settles, the record IS the question's presentation, so + * count records rather than every node repeating their text — an + * expanded record (Auto-expand tool details) shows the question in both + * its summary line and its panel, and matching text would count one + * record twice. Before a record exists the live pause renders the + * question as a paragraph. */ + questionCount: + askRecordCount > 0 ? askRecordCount : visibleTextCount(state, 'p', HITL_QUESTION), answerOptionCount: visibleTextCount(state, 'button', HITL_OPTION, true), finalReplyCount: messageElements.filter((element) => (element.textContent ?? '').includes(FINAL_REPLY), ).length, - completedAnswerCount: messageElements.filter((element) => - (element.textContent ?? '').includes(COMPLETED_ANSWER), - ).length, + completedAnswerCount: messageElements.filter((element) => { + const text = element.textContent ?? ''; + return text.includes(COMPLETED_ANSWER_LABEL) && text.includes(HITL_OPTION); + }).length, isSubmitting: state.document.querySelector('button[aria-label="Stop generating"]') !== null, hasComposer: state.document.querySelector('#prompt-textarea') !== null, loginEmail: target(state, '#email', 'Login email'), diff --git a/e2e/specs/mock/tool-approvals.spec.ts b/e2e/specs/mock/tool-approvals.spec.ts index 7e38a4059b7..21517fb4eaa 100644 --- a/e2e/specs/mock/tool-approvals.spec.ts +++ b/e2e/specs/mock/tool-approvals.spec.ts @@ -169,28 +169,37 @@ async function expectCompletedApprovalToolOutput(page: Page, toolCallId: string, // start collapsed. Wait for either the target card or its group before // deciding whether expansion is necessary. await expect(toolCall.or(groupToggle).first()).toBeVisible({ timeout: 30000 }); - if ( - !(await toolCall.isVisible()) && - (await groupToggle.getAttribute('aria-expanded')) !== 'true' - ) { - await groupToggle.click(); - } + // The final model turn is the quiescence barrier: all parallel tool work + // has settled before invocation-count assertions inspect the audit. It is + // also the fence the expansions below need, because the streamed response + // carries a placeholder id that the saved message replaces, remounting + // every card in the turn and closing whatever this helper had opened. + await expect(view.getByText(/^E2E approval outcomes:/).last()).toBeVisible({ timeout: 30000 }); - await expect(toolCall).toBeVisible({ timeout: 30000 }); const toggle = toolCall.getByRole('button', { name: /Ran approval_probe/ }); - await expect(toggle).toBeVisible({ timeout: 30000 }); - if ((await toggle.getAttribute('aria-expanded')) !== 'true') { - await toggle.click(); - } - // Scope exact output to its stable call id. This catches both a dropped // completion and an output accidentally attached to a sibling tool card. - await expect( - view.locator(`[data-tool-call-output-id="${toolCallId}"]`).getByText(output, { exact: true }), - ).toBeVisible({ timeout: 30000 }); - // The final model turn is the quiescence barrier: all parallel tool work - // has settled before invocation-count assertions inspect the audit. - await expect(view.getByText(/^E2E approval outcomes:/).last()).toBeVisible({ timeout: 30000 }); + const toolOutput = view + .locator(`[data-tool-call-output-id="${toolCallId}"]`) + .getByText(output, { exact: true }); + + // Re-open on every attempt rather than expanding once: a card that a late + // remount closes underneath would otherwise leave the assertion waiting on + // a body that nothing is going to mount again. + await expect(async () => { + if (!(await toolCall.isVisible())) { + const hasGroup = (await groupToggle.count()) > 0; + if (hasGroup && (await groupToggle.getAttribute('aria-expanded')) !== 'true') { + await groupToggle.click(); + } + } + await expect(toolCall).toBeVisible({ timeout: 5000 }); + await expect(toggle).toBeVisible({ timeout: 5000 }); + if ((await toggle.getAttribute('aria-expanded')) !== 'true') { + await toggle.click(); + } + await expect(toolOutput).toBeVisible({ timeout: 5000 }); + }).toPass({ timeout: 30000 }); } test.describe('tool approvals', () => { diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index 8138eb2e047..b517ae49789 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -2540,6 +2540,32 @@ describe('initializeAgent — run-scoped MCP tool definitions', () => { ); }); + it('threads the normalized MCP request body into tool discovery', async () => { + const { agent, req, res, loadTools, db } = createMocks(); + agent.tools = ['custom_tool']; + const requestBody = { + messageId: 'message-1', + conversationId: 'conversation-1', + parentMessageId: 'parent-1', + }; + + await initializeAgent( + { + req, + res, + agent, + loadTools, + requestBody, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.OPENAI]), + isInitialAgent: true, + }, + db, + ); + + expect(loadTools).toHaveBeenCalledWith(expect.objectContaining({ requestBody })); + }); + it('unions snapshot config names into the audit when the merged read omits them', async () => { /** The registry's merged read tolerates config-server init failures and * can silently drop config-only servers — the heal audit must restore diff --git a/packages/api/src/agents/activity.spec.ts b/packages/api/src/agents/activity.spec.ts new file mode 100644 index 00000000000..d2b397771b8 --- /dev/null +++ b/packages/api/src/agents/activity.spec.ts @@ -0,0 +1,262 @@ +import { projectSubagentActivity, SUBAGENT_ACTIVITY_LIMITS } from './activity'; + +describe('durable subagent activity projection', () => { + it('keeps visible text and tool lifecycle while dropping private metadata and reasoning text', () => { + const projection = projectSubagentActivity( + JSON.stringify([ + { + type: 'ai', + data: { + content: [ + { type: 'reasoning', reasoning: 'private chain of thought' }, + { type: 'text', text: 'I will check.' }, + ], + tool_calls: [{ id: 'call-1', name: 'search', args: { query: 'release' } }], + response_metadata: { providerRequestId: 'private-request' }, + }, + }, + { + type: 'tool', + data: { + tool_call_id: 'call-1', + name: 'search', + content: 'Found it.', + status: 'success', + artifact: { secret: 'never expose' }, + }, + }, + ]), + ); + + expect(projection).toEqual({ + activity: [ + { type: 'reasoning' }, + { type: 'writing', text: 'I will check.' }, + { + type: 'tool', + toolCallId: 'call-1', + name: 'search', + input: '{"query":"release"}', + output: 'Found it.', + status: 'completed', + }, + ], + truncated: false, + }); + expect(JSON.stringify(projection)).not.toContain('private chain of thought'); + expect(JSON.stringify(projection)).not.toContain('private-request'); + expect(JSON.stringify(projection)).not.toContain('never expose'); + }); + + it('fails closed on invalid input and bounds adversarial activity', () => { + expect(projectSubagentActivity('{')).toEqual({ activity: [], truncated: true }); + + const projection = projectSubagentActivity( + JSON.stringify( + Array.from({ length: 500 }, (_, index) => ({ + type: 'ai', + data: { + content: '🧵'.repeat(SUBAGENT_ACTIVITY_LIMITS.textBytes), + tool_calls: [ + { + id: `call-${index}`, + name: 'tool', + args: { value: 'x'.repeat(SUBAGENT_ACTIVITY_LIMITS.toolInputBytes * 2) }, + }, + ], + }, + })), + ), + ); + + expect(projection.truncated).toBe(true); + expect(projection.activity.length).toBeLessThanOrEqual(SUBAGENT_ACTIVITY_LIMITS.items); + expect(Buffer.byteLength(JSON.stringify(projection.activity), 'utf8')).toBeLessThanOrEqual( + SUBAGENT_ACTIVITY_LIMITS.bytes, + ); + expect(projection.activity[projection.activity.length - 1]).toEqual( + expect.objectContaining({ type: 'tool', toolCallId: 'call-499' }), + ); + }); + + it.each([ + ['error', 'failed'], + ['failed', 'failed'], + ['cancelled', 'cancelled'], + ['success', 'completed'], + ] as const)('maps a %s tool result to the %s public lifecycle', (stored, expected) => { + const projection = projectSubagentActivity( + JSON.stringify([ + { type: 'ai', data: { tool_calls: [{ id: 'call', name: 'search', args: {} }] } }, + { type: 'tool', data: { tool_call_id: 'call', name: 'search', status: stored } }, + ]), + ); + + expect(projection.activity[0]).toEqual(expect.objectContaining({ status: expected })); + }); + + it('shows only the selected invocation segment from a replacement transcript', () => { + const projection = projectSubagentActivity( + JSON.stringify([ + { type: 'human', data: { content: 'Earlier request.' } }, + { type: 'ai', data: { content: 'Earlier private activity.' } }, + { type: 'human', data: { content: 'Selected request.' } }, + { type: 'ai', data: { content: 'Selected activity.' } }, + ]), + 'replace', + 'Selected request.', + ); + + expect(projection.activity).toEqual([{ type: 'writing', text: 'Selected activity.' }]); + expect(JSON.stringify(projection)).not.toContain('Earlier private activity.'); + expect( + projectSubagentActivity('[{"type":"ai","data":{"content":"old"}}]', 'replace', 'new'), + ).toEqual({ activity: [], truncated: true }); + expect( + projectSubagentActivity( + '[{"type":"human","data":{"content":"different"}}]', + 'replace', + 'selected', + ), + ).toEqual({ activity: [], truncated: true }); + expect( + projectSubagentActivity('[{"type":"human","data":{"content":"selected"}}]', 'replace'), + ).toEqual({ activity: [], truncated: true }); + }); + + it('correlates repeated provider tool IDs by occurrence without merging their results', () => { + const projection = projectSubagentActivity( + JSON.stringify([ + { type: 'ai', data: { tool_calls: [{ id: 'call', name: 'first', args: {} }] } }, + { type: 'ai', data: { tool_calls: [{ id: 'call', name: 'second', args: {} }] } }, + { type: 'tool', data: { tool_call_id: 'call', content: 'first result' } }, + { type: 'tool', data: { tool_call_id: 'call', content: 'second result' } }, + ]), + ); + + expect(projection.activity).toEqual([ + expect.objectContaining({ + toolCallId: 'call', + name: 'first', + output: 'first result', + status: 'completed', + }), + expect.objectContaining({ + toolCallId: 'call#2', + name: 'second', + output: 'second result', + status: 'completed', + }), + ]); + }); + + it('correlates a large repeated-ID queue in FIFO order within the item cap', () => { + const count = SUBAGENT_ACTIVITY_LIMITS.items * 3; + const projection = projectSubagentActivity( + JSON.stringify([ + ...Array.from({ length: count }, (_, index) => ({ + type: 'ai', + data: { tool_calls: [{ id: 'call', name: `tool-${index}`, args: {} }] }, + })), + ...Array.from({ length: count }, (_, index) => ({ + type: 'tool', + data: { tool_call_id: 'call', content: `result-${index}` }, + })), + ]), + ); + + expect(projection.activity).toHaveLength(SUBAGENT_ACTIVITY_LIMITS.items); + expect(projection.activity[0]).toEqual( + expect.objectContaining({ + toolCallId: 'call#201', + name: 'tool-200', + output: 'result-200', + }), + ); + expect(projection.activity[projection.activity.length - 1]).toEqual( + expect.objectContaining({ + toolCallId: 'call#300', + name: 'tool-299', + output: 'result-299', + }), + ); + }); + + it('allocates suffixes globally when long provider IDs share a truncated namespace', () => { + const prefix = 'x'.repeat(510); + const first = `${prefix}aa`; + const second = `${prefix}bb`; + const projection = projectSubagentActivity( + JSON.stringify([ + { + type: 'ai', + data: { + tool_calls: [ + { id: first, name: 'first-a' }, + { id: first, name: 'first-b' }, + { id: second, name: 'second-a' }, + { id: second, name: 'second-b' }, + ], + }, + }, + ]), + ); + + const ids = projection.activity.flatMap((item) => + item.type === 'tool' ? [item.toolCallId] : [], + ); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).toEqual([first, `${prefix}#2`, second, `${prefix}#3`]); + }); + + it('retains a late tool completion even when its declaration predates the item tail', () => { + const projection = projectSubagentActivity( + JSON.stringify([ + { type: 'ai', data: { tool_calls: [{ id: 'early', name: 'search', args: {} }] } }, + ...Array.from({ length: SUBAGENT_ACTIVITY_LIMITS.items + 10 }, (_, index) => ({ + type: 'ai', + data: { content: `update-${index}` }, + })), + { type: 'tool', data: { tool_call_id: 'early', content: 'late result' } }, + ]), + ); + + expect(projection.truncated).toBe(true); + expect(projection.activity[projection.activity.length - 1]).toEqual( + expect.objectContaining({ + type: 'tool', + toolCallId: 'early', + output: 'late result', + status: 'completed', + }), + ); + }); + + it('keeps an escape-heavy terminal result within the serialized byte cap', () => { + const projection = projectSubagentActivity( + JSON.stringify([ + { type: 'ai', data: { tool_calls: [{ id: 'terminal', name: 'compute', args: {} }] } }, + { + type: 'tool', + data: { + tool_call_id: 'terminal', + content: '\u0000'.repeat(SUBAGENT_ACTIVITY_LIMITS.toolOutputBytes), + }, + }, + ]), + ); + + expect(projection.truncated).toBe(true); + expect(Buffer.byteLength(JSON.stringify(projection.activity), 'utf8')).toBeLessThanOrEqual( + SUBAGENT_ACTIVITY_LIMITS.bytes, + ); + expect(projection.activity).toEqual([ + expect.objectContaining({ + type: 'tool', + toolCallId: 'terminal', + status: 'completed', + outputTruncated: true, + }), + ]); + }); +}); diff --git a/packages/api/src/agents/activity.ts b/packages/api/src/agents/activity.ts new file mode 100644 index 00000000000..e4cbe20a119 --- /dev/null +++ b/packages/api/src/agents/activity.ts @@ -0,0 +1,317 @@ +import type { SubagentActivityItem } from 'librechat-data-provider'; + +const MAX_ACTIVITY_ITEMS = 100; +const MAX_ACTIVITY_BYTES = 64 * 1024; +const MAX_ACTIVITY_TEXT_BYTES = 32 * 1024; +const MAX_TOOL_INPUT_BYTES = 8 * 1024; +const MAX_TOOL_OUTPUT_BYTES = 16 * 1024; +const MAX_TOOL_NAME_BYTES = 512; +const MAX_TOOL_CALL_ID_BYTES = 512; + +type Projection = { + activity: SubagentActivityItem[]; + truncated: boolean; +}; + +type MutableToolActivity = Extract; +type ProjectedActivityEntry = { item: SubagentActivityItem; active: boolean }; +type MutableToolProjection = { + item: MutableToolActivity; + entry: ProjectedActivityEntry; +}; +type MutableToolQueue = { + items: MutableToolProjection[]; + nextPending: number; +}; + +const toolResultStatus = (value: unknown): MutableToolActivity['status'] => { + if (value === 'error' || value === 'failed') return 'failed'; + if (value === 'cancelled') return 'cancelled'; + return 'completed'; +}; + +const isRecord = (value: unknown): value is Record => + value != null && typeof value === 'object' && !Array.isArray(value); + +const truncateUtf8 = (input: string, byteLimit: number) => { + if (Buffer.byteLength(input, 'utf8') <= byteLimit) { + return { value: input, truncated: false }; + } + let low = 0; + let high = input.length; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if (Buffer.byteLength(input.slice(0, middle), 'utf8') <= byteLimit) { + low = middle; + } else { + high = middle - 1; + } + } + let end = low; + if (end > 0 && /[\uD800-\uDBFF]/.test(input[end - 1])) end -= 1; + return { value: input.slice(0, end), truncated: true }; +}; + +const safeJson = (value: unknown): string => { + if (typeof value === 'string') return value; + try { + return JSON.stringify(value) ?? ''; + } catch { + return ''; + } +}; + +const serializedBytes = (value: unknown): number => + Buffer.byteLength(JSON.stringify(value), 'utf8'); + +const shrinkStringField = ( + item: T, + field: keyof T, + truncatedField?: keyof T, +): T => { + const current = item[field]; + if (typeof current !== 'string') return item; + const base = { + ...item, + [field]: '', + ...(truncatedField == null ? {} : { [truncatedField]: true }), + } as T; + if (serializedBytes([base]) > MAX_ACTIVITY_BYTES) return base; + let low = 0; + let high = current.length; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + const candidate = { ...base, [field]: current.slice(0, middle) } as T; + if (serializedBytes([candidate]) <= MAX_ACTIVITY_BYTES) { + low = middle; + } else { + high = middle - 1; + } + } + return { ...base, [field]: current.slice(0, low) } as T; +}; + +const fitNewestItemToSerializedBudget = (item: SubagentActivityItem): SubagentActivityItem => { + if (serializedBytes([item]) <= MAX_ACTIVITY_BYTES) return item; + if (item.type === 'writing') return shrinkStringField(item, 'text', 'textTruncated'); + if (item.type === 'reasoning') return item; + + // Preserve the terminal output as long as possible: discard oversized input + // first, then trim output and finally public identity fields if a provider + // supplied escape-heavy strings. + let tool = shrinkStringField(item, 'input', 'inputTruncated'); + if (serializedBytes([tool]) <= MAX_ACTIVITY_BYTES) return tool; + tool = shrinkStringField(tool, 'output', 'outputTruncated'); + if (serializedBytes([tool]) <= MAX_ACTIVITY_BYTES) return tool; + tool = shrinkStringField(tool, 'name'); + if (serializedBytes([tool]) <= MAX_ACTIVITY_BYTES) return tool; + return shrinkStringField(tool, 'toolCallId'); +}; + +const visibleContent = (value: unknown): { text: string; hasReasoning: boolean } => { + if (typeof value === 'string') return { text: value, hasReasoning: false }; + if (!Array.isArray(value)) return { text: '', hasReasoning: false }; + const text: string[] = []; + let hasReasoning = false; + for (const block of value) { + if (!isRecord(block) || typeof block.type !== 'string') continue; + if ((block.type === 'text' || block.type === 'text-plain') && typeof block.text === 'string') { + text.push(block.text); + } else if (block.type === 'reasoning' || block.type === 'thinking') { + // Preserve the user-visible lifecycle marker, never the model's hidden reasoning payload. + hasReasoning = true; + } + } + return { text: text.join(''), hasReasoning }; +}; + +const readToolCalls = (data: Record): unknown[] => { + if (Array.isArray(data.tool_calls)) return data.tool_calls; + const additional = isRecord(data.additional_kwargs) ? data.additional_kwargs : undefined; + return Array.isArray(additional?.tool_calls) ? additional.tool_calls : []; +}; + +const normalizeToolCall = ( + value: unknown, + index: number, +): { rawId: string; item: MutableToolActivity } | undefined => { + if (!isRecord(value)) return undefined; + const fn = isRecord(value.function) ? value.function : undefined; + const rawName = typeof value.name === 'string' ? value.name : fn?.name; + if (typeof rawName !== 'string' || rawName.trim() === '') return undefined; + const rawId = typeof value.id === 'string' && value.id !== '' ? value.id : `tool-${index}`; + const rawInput = value.args ?? fn?.arguments; + const input = truncateUtf8(safeJson(rawInput), MAX_TOOL_INPUT_BYTES); + return { + rawId, + item: { + type: 'tool', + toolCallId: truncateUtf8(rawId, MAX_TOOL_CALL_ID_BYTES).value, + name: truncateUtf8(rawName, MAX_TOOL_NAME_BYTES).value, + ...(input.value === '' ? {} : { input: input.value }), + ...(input.truncated ? { inputTruncated: true } : {}), + status: 'running', + }, + }; +}; + +const uniqueToolActivityId = ( + rawId: string, + used: Set, + nextGeneratedOccurrence: { value: number }, +): string => { + const base = truncateUtf8(rawId, MAX_TOOL_CALL_ID_BYTES).value || 'tool'; + let candidate = base; + while (used.has(candidate)) { + const suffix = `#${nextGeneratedOccurrence.value}`; + nextGeneratedOccurrence.value += 1; + const prefix = truncateUtf8(base, MAX_TOOL_CALL_ID_BYTES - Buffer.byteLength(suffix)).value; + candidate = `${prefix}${suffix}`; + } + used.add(candidate); + return candidate; +}; + +/** + * Converts one server-private LangChain transcript into a bounded public + * activity projection. Only visible text and declared tool calls/results are + * retained; response metadata, artifacts, runtime fields, and reasoning text + * are intentionally ignored. + */ +export function projectSubagentActivity( + messagesJson: string | undefined, + mode: 'append' | 'replace' = 'append', + expectedTaskInput?: string, +): Projection { + if (messagesJson == null) return { activity: [], truncated: false }; + let parsed: unknown; + try { + parsed = JSON.parse(messagesJson) as unknown; + } catch { + return { activity: [], truncated: true }; + } + if (!Array.isArray(parsed)) return { activity: [], truncated: true }; + let relevantMessages = parsed; + if (mode === 'replace') { + if (expectedTaskInput == null) return { activity: [], truncated: true }; + let latestInputIndex = -1; + for (let index = parsed.length - 1; index >= 0; index -= 1) { + const stored = parsed[index]; + if (isRecord(stored) && (stored.type === 'human' || stored.type === 'user')) { + latestInputIndex = index; + break; + } + } + // A replacement transcript can contain the complete child history. If + // its current input boundary is missing, fail closed instead of exposing + // activity from earlier invocations on the selected parent card. + if ( + latestInputIndex < 0 || + !isRecord(parsed[latestInputIndex]) || + !isRecord(parsed[latestInputIndex].data) || + visibleContent(parsed[latestInputIndex].data.content).text !== expectedTaskInput + ) { + return { activity: [], truncated: true }; + } + relevantMessages = parsed.slice(latestInputIndex + 1); + } + + const activity: ProjectedActivityEntry[] = []; + const toolsByRawId = new Map(); + const usedToolActivityIds = new Set(); + // A global cursor makes collision probing amortized linear even when many + // maximum-length provider IDs collapse to the same suffixed prefix. + const nextGeneratedToolOccurrence = { value: 2 }; + let truncated = false; + const append = (item: SubagentActivityItem) => { + const entry = { item, active: true }; + activity.push(entry); + return entry; + }; + + for (const stored of relevantMessages) { + if (!isRecord(stored) || !isRecord(stored.data) || typeof stored.type !== 'string') { + truncated = true; + continue; + } + const { data } = stored; + if (stored.type === 'ai' || stored.type === 'assistant') { + const content = visibleContent(data.content); + if (content.hasReasoning) append({ type: 'reasoning' }); + if (content.text !== '') { + const text = truncateUtf8(content.text, MAX_ACTIVITY_TEXT_BYTES); + append({ + type: 'writing', + text: text.value, + ...(text.truncated ? { textTruncated: true } : {}), + }); + } + readToolCalls(data).forEach((call, index) => { + const normalized = normalizeToolCall(call, index); + if (normalized == null) return; + normalized.item.toolCallId = uniqueToolActivityId( + normalized.item.toolCallId, + usedToolActivityIds, + nextGeneratedToolOccurrence, + ); + const entry = append(normalized.item); + const queue = toolsByRawId.get(normalized.rawId) ?? { items: [], nextPending: 0 }; + queue.items.push({ item: normalized.item, entry }); + toolsByRawId.set(normalized.rawId, queue); + }); + continue; + } + if (stored.type !== 'tool') continue; + const toolCallId = typeof data.tool_call_id === 'string' ? data.tool_call_id : ''; + const output = truncateUtf8(visibleContent(data.content).text, MAX_TOOL_OUTPUT_BYTES); + const queue = toolsByRawId.get(toolCallId); + const existing = queue?.items[queue.nextPending]; + if (queue != null && existing != null) { + queue.nextPending += 1; + existing.item.status = toolResultStatus(data.status); + if (output.value !== '') existing.item.output = output.value; + if (output.truncated) existing.item.outputTruncated = true; + existing.entry.active = false; + existing.entry = append(existing.item); + continue; + } + const name = typeof data.name === 'string' && data.name !== '' ? data.name : 'tool'; + const projectedToolCallId = uniqueToolActivityId( + toolCallId || `tool-result-${activity.length}`, + usedToolActivityIds, + nextGeneratedToolOccurrence, + ); + const orphan: MutableToolActivity = { + type: 'tool', + toolCallId: projectedToolCallId, + name: truncateUtf8(name, MAX_TOOL_NAME_BYTES).value, + ...(output.value === '' ? {} : { output: output.value }), + ...(output.truncated ? { outputTruncated: true } : {}), + status: toolResultStatus(data.status), + }; + append(orphan); + } + + let boundedActivity = activity.filter((entry) => entry.active).map((entry) => entry.item); + if (boundedActivity.length > MAX_ACTIVITY_ITEMS) { + boundedActivity = boundedActivity.slice(-MAX_ACTIVITY_ITEMS); + truncated = true; + } + while (boundedActivity.length > 1 && serializedBytes(boundedActivity) > MAX_ACTIVITY_BYTES) { + boundedActivity.shift(); + truncated = true; + } + if (boundedActivity.length === 1 && serializedBytes(boundedActivity) > MAX_ACTIVITY_BYTES) { + boundedActivity[0] = fitNewestItemToSerializedBudget(boundedActivity[0]); + truncated = true; + } + return { activity: boundedActivity, truncated }; +} + +export const SUBAGENT_ACTIVITY_LIMITS = { + items: MAX_ACTIVITY_ITEMS, + bytes: MAX_ACTIVITY_BYTES, + textBytes: MAX_ACTIVITY_TEXT_BYTES, + toolInputBytes: MAX_TOOL_INPUT_BYTES, + toolOutputBytes: MAX_TOOL_OUTPUT_BYTES, +} as const; diff --git a/packages/api/src/agents/discovery.spec.ts b/packages/api/src/agents/discovery.spec.ts index 10ee3a5190c..dce09b5425a 100644 --- a/packages/api/src/agents/discovery.spec.ts +++ b/packages/api/src/agents/discovery.spec.ts @@ -273,6 +273,40 @@ describe('discoverConnectedAgents', () => { ); }); + it('forwards normalized request metadata to every handoff initializeAgent call', async () => { + const primaryConfig = makeConfig('A', [{ from: 'A', to: 'B', edgeType: 'handoff' }]); + const getAgent = jest.fn(async () => makeAgent('B', [])); + const checkPermission = jest.fn().mockResolvedValue(true); + const requestBody = { + messageId: 'message-1', + conversationId: 'conversation-1', + parentMessageId: 'parent-1', + }; + + await discoverConnectedAgents( + { + req: makeReq(), + res: makeRes(), + primaryConfig, + allowedProviders: new Set(), + modelsConfig: { openai: ['gpt-4o'] }, + loadTools: jest.fn(), + requestBody, + }, + { + getAgent, + checkPermission, + logViolation: jest.fn(), + db: {} as never, + }, + ); + + expect(mockInitializeAgent).toHaveBeenCalledWith( + expect.objectContaining({ requestBody }), + expect.anything(), + ); + }); + it('forwards codeEnvAvailable=false verbatim so handoff agents respect disabled capability', async () => { /* Symmetric to the "true" case: when the primary resolved `codeEnvAvailable = false`, handoffs must NOT accidentally diff --git a/packages/api/src/agents/discovery.ts b/packages/api/src/agents/discovery.ts index 2fb107940e4..f525481f237 100644 --- a/packages/api/src/agents/discovery.ts +++ b/packages/api/src/agents/discovery.ts @@ -69,6 +69,8 @@ export interface DiscoverConnectedAgentsParams { requestFiles?: InitializeAgentParams['requestFiles']; conversationId?: string | null; parentMessageId?: string | null; + /** Normalized runtime request metadata forwarded to MCP tool loading. */ + requestBody?: InitializeAgentParams['requestBody']; /** * ResourceType to check each sub-agent's access against. Defaults to * `AGENT` for the in-app chat flow. Callers whose entry-point gates on @@ -230,6 +232,7 @@ async function initializeReferencedAgent( requestFiles: params.requestFiles, conversationId: params.conversationId, parentMessageId: params.parentMessageId, + requestBody: params.requestBody, endpointOption: { ...(params.endpointOption ?? {}), endpoint: EModelEndpoint.agents, diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 4e0442b4ef6..48d02050602 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -78,6 +78,8 @@ export interface ToolExecuteOptions { loadTools: ( toolNames: string[], agentId?: string, + /** Immutable run configuration available before deferred tools connect. */ + configurable?: Record, ) => Promise<{ loadedTools: StructuredToolInterface[]; /** Additional configurable properties to merge (e.g., userMCPAuthMap) */ @@ -3848,12 +3850,13 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand await runOutsideTracing(async () => { try { const toolNames = [...new Set(toolCalls.map((tc: ToolCallRequest) => tc.name))]; + const sourceConfigurable = configurable as Record | undefined; const { loadedTools, configurable: toolConfigurable } = await loadTools( toolNames, agentId, + sourceConfigurable, ); const toolMap = new Map(loadedTools.map((t) => [t.name, t])); - const sourceConfigurable = configurable as Record | undefined; const loadedConfigurable = toolConfigurable as Record | undefined; const mergedConfigurable = mergeToolConfigurables( sourceConfigurable, diff --git a/packages/api/src/agents/hitl/policy.spec.ts b/packages/api/src/agents/hitl/policy.spec.ts index 1a43846d546..e95f3b034ac 100644 --- a/packages/api/src/agents/hitl/policy.spec.ts +++ b/packages/api/src/agents/hitl/policy.spec.ts @@ -2,6 +2,9 @@ import type { Agents, TToolApprovalPolicy } from 'librechat-data-provider'; import { resolveToolApprovalPolicy, isHITLEnabled, + healToolApprovalPolicy, + collectAliasMatcherNames, + buildAliasMatcherPattern, mapToolApprovalPolicy, buildToolApprovalPayload, buildAskUserQuestionPayload, @@ -773,3 +776,87 @@ describe('exemptAskUserQuestionFromApproval', () => { expect(exemptAskUserQuestionFromApproval(undefined, NAME)).toBeUndefined(); }); }); + +describe('healToolApprovalPolicy', () => { + const aliases = [ + { name: 'delete_thing_mcp_acme', aliasName: 'acme_delete_thing_mcp_acme' }, + { name: 'search_mcp_acme', aliasName: 'acme_search_mcp_acme' }, + ]; + + it('appends current names to lists whose patterns match only the legacy spelling', () => { + /** Admin YAML written against upstream naming must keep applying — a + * non-matching deny fails OPEN. */ + const healed = healToolApprovalPolicy( + { enabled: true, deny: ['acme_delete_*'], ask: ['acme_search_mcp_acme'] }, + aliases, + ); + + expect(healed?.deny).toEqual(['acme_delete_*', 'delete_thing_mcp_acme']); + expect(healed?.ask).toEqual(['acme_search_mcp_acme', 'search_mcp_acme']); + }); + + it('heals list-level so allow semantics are preserved, not tightened', () => { + const healed = healToolApprovalPolicy({ enabled: true, allow: ['acme_search_*'] }, aliases); + + expect(healed?.allow).toEqual(['acme_search_*', 'search_mcp_acme']); + }); + + it('skips names the list already matches and leaves non-matching lists untouched', () => { + const healed = healToolApprovalPolicy( + { enabled: true, deny: ['*_mcp_acme'], allow: ['unrelated_tool'] }, + aliases, + ); + + expect(healed?.deny).toEqual(['*_mcp_acme']); + expect(healed?.allow).toEqual(['unrelated_tool']); + }); + + it('passes through without aliases or policy', () => { + expect(healToolApprovalPolicy(undefined, aliases)).toBeUndefined(); + const policy: TToolApprovalPolicy = { enabled: true, deny: ['x'] }; + expect(healToolApprovalPolicy(policy, [])).toBe(policy); + }); +}); + +describe('healToolApprovalPolicy reverse direction', () => { + it('appends a legacy-named instance when the pattern targets the current catalog name', () => { + /** An unedited agent retains the pre-strip instance name — a deny written + * against the current catalog name must still reach it. */ + const aliases = [{ name: 'acme_search_mcp_acme', aliasName: 'search_mcp_acme' }]; + const healed = healToolApprovalPolicy( + { enabled: true, mode: 'bypass', deny: ['search_mcp_acme'] }, + aliases, + ); + + expect(healed?.deny).toEqual(['search_mcp_acme', 'acme_search_mcp_acme']); + }); +}); + +describe('collectAliasMatcherNames', () => { + const aliases = [ + { name: 'search_mcp_acme', aliasName: 'acme_search_mcp_acme' }, + { name: 'acme_list_mcp_acme', aliasName: 'list_mcp_acme' }, + ]; + + it('returns names whose alias matches the regex while the name does not', () => { + expect(collectAliasMatcherNames('^acme_search_mcp_acme$', aliases)).toEqual([ + 'search_mcp_acme', + ]); + expect(collectAliasMatcherNames('^list_mcp_acme$', aliases)).toEqual(['acme_list_mcp_acme']); + }); + + it('skips names the matcher already matches and invalid patterns', () => { + expect(collectAliasMatcherNames('_mcp_acme$', aliases)).toEqual([]); + expect(collectAliasMatcherNames('(unclosed', aliases)).toEqual([]); + expect(collectAliasMatcherNames(undefined, aliases)).toEqual([]); + }); + + it('builds an anchored exact-name pattern with escaped names', () => { + const pattern = buildAliasMatcherPattern(['a.b_mcp_acme', 'c_mcp_acme']); + const regex = new RegExp(pattern); + expect(regex.test('a.b_mcp_acme')).toBe(true); + expect(regex.test('axb_mcp_acme')).toBe(false); + expect(regex.test('c_mcp_acme')).toBe(true); + expect(regex.test('xc_mcp_acme')).toBe(false); + }); +}); diff --git a/packages/api/src/agents/hitl/policy.ts b/packages/api/src/agents/hitl/policy.ts index 535ce37d72b..d3350ec0632 100644 --- a/packages/api/src/agents/hitl/policy.ts +++ b/packages/api/src/agents/hitl/policy.ts @@ -2,6 +2,7 @@ import { randomUUID, createHash } from 'crypto'; import { openAIBaseSchema, googleBaseSchema, anthropicBaseSchema } from 'librechat-data-provider'; import type { Agents, TToolApprovalPolicy } from 'librechat-data-provider'; import type { ToolPolicyConfig } from '@librechat/agents'; +import type { MCPToolAlias } from '~/tools/classification'; /** * Default decisions offered to the user for a paused tool call. @@ -86,6 +87,93 @@ export function isHITLEnabled(policy: TToolApprovalPolicy | undefined): boolean * defaults apply). The `enabled` field is LibreChat-only and stripped here — * it's consumed separately via {@link isHITLEnabled} to gate the SDK opt-out. */ +/** Anchored-glob matcher mirroring the SDK's `createToolPolicyHook` semantics exactly. */ +function globToRegex(pattern: string): RegExp { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp('^' + escaped.replace(/\*/g, '.*') + '$'); +} + +/** + * Extends each `toolApproval` pattern list with the names of tools whose + * OTHER spelling matches, so admin YAML keeps applying when a tool's key + * spelling changed in either direction: patterns written against pre-strip + * upstream naming reach the stripped instances (a non-matching `deny` would + * otherwise FAIL OPEN), and patterns written against the current catalog + * naming reach legacy-named instances retained by unedited agents. Healing + * is list-level (literal names appended, patterns never rewritten), so + * `deny`/`ask`/`allow` precedence semantics are unchanged, and a name + * already matched by its own list is skipped. + */ +export function healToolApprovalPolicy( + policy: TToolApprovalPolicy | undefined, + aliases: readonly MCPToolAlias[], +): TToolApprovalPolicy | undefined { + if (!policy || aliases.length === 0) { + return policy; + } + const healList = (patterns: string[] | undefined): string[] | undefined => { + if (!patterns || patterns.length === 0) { + return patterns; + } + const regexes = patterns.map(globToRegex); + const appended: string[] = []; + for (const { name, aliasName } of aliases) { + if (name === aliasName || regexes.some((regex) => regex.test(name))) { + continue; + } + if (regexes.some((regex) => regex.test(aliasName))) { + appended.push(name); + } + } + return appended.length > 0 ? [...patterns, ...appended] : patterns; + }; + return { + ...policy, + allow: healList(policy.allow), + deny: healList(policy.deny), + ask: healList(policy.ask), + }; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Names whose OTHER spelling matches a programmatic hook's regex matcher + * while their own name does not — the hook must also fire for these or its + * argument-, user-, or tenant-specific deny/ask decisions are silently + * skipped for renamed tools. Mirrors the SDK's unanchored `new RegExp(pattern)` + * matcher semantics; an invalid pattern matches nothing there, so it aliases + * nothing here. + */ +export function collectAliasMatcherNames( + matcher: string | undefined, + aliases: readonly MCPToolAlias[], +): string[] { + if (!matcher || aliases.length === 0) { + return []; + } + let regex: RegExp; + try { + regex = new RegExp(matcher); + } catch { + return []; + } + const names: string[] = []; + for (const { name, aliasName } of aliases) { + if (name !== aliasName && !regex.test(name) && regex.test(aliasName)) { + names.push(name); + } + } + return names; +} + +/** Anchored exact-name pattern for the alias-matched names of one hook matcher. */ +export function buildAliasMatcherPattern(names: readonly string[]): string { + return `^(?:${names.map(escapeRegExp).join('|')})$`; +} + export function mapToolApprovalPolicy( policy: TToolApprovalPolicy | undefined, ): ToolPolicyConfig | undefined { diff --git a/packages/api/src/agents/hitl/runtime.ts b/packages/api/src/agents/hitl/runtime.ts index b7c7404ad8a..5d96ae05a38 100644 --- a/packages/api/src/agents/hitl/runtime.ts +++ b/packages/api/src/agents/hitl/runtime.ts @@ -1,7 +1,13 @@ import { HookRegistry, createToolPolicyHook } from '@librechat/agents'; import type { TToolApprovalPolicy } from 'librechat-data-provider'; +import type { MCPToolAlias } from '~/tools/classification'; import type { ToolApprovalHookContext } from './hooks'; -import { isHITLEnabled, mapToolApprovalPolicy } from './policy'; +import { + isHITLEnabled, + mapToolApprovalPolicy, + collectAliasMatcherNames, + buildAliasMatcherPattern, +} from './policy'; import { buildToolApprovalHooks } from './hooks'; /** @@ -33,6 +39,7 @@ export interface HITLRunWiring { export function buildHITLRunWiring( policy: TToolApprovalPolicy | undefined, context: ToolApprovalHookContext = {}, + mcpToolAliases: readonly MCPToolAlias[] = [], ): HITLRunWiring | undefined { if (!isHITLEnabled(policy)) { return undefined; @@ -52,6 +59,20 @@ export function buildHITLRunWiring( 'PreToolUse', matcher ? { pattern: matcher, hooks: [hook] } : { hooks: [hook] }, ); + /** A matcher written against a tool's OTHER key spelling (pre-strip or + * current) would silently never fire for the renamed instance, skipping + * its argument/user/tenant-specific deny or ask. The SAME hook is + * registered again under an exact-name pattern for those aliased names + * — a separate entry keeps the admin's regex semantics and the SDK's + * pattern-length cap intact, and the name sets are disjoint so the hook + * never fires twice for one call. */ + const aliasNames = matcher ? collectAliasMatcherNames(matcher, mcpToolAliases) : []; + if (aliasNames.length > 0) { + registry.register('PreToolUse', { + pattern: buildAliasMatcherPattern(aliasNames), + hooks: [hook], + }); + } } return { humanInTheLoop: { enabled: true }, hooks: registry }; diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 7e49a30a913..bcc08733197 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -26,20 +26,22 @@ import type { import type { GenericTool, LCToolRegistry, ToolMap, LCTool } from '@librechat/agents'; import type { IMongoFile, FileOwnerScope } from '@librechat/data-schemas'; import type { Response as ServerResponse } from 'express'; -import type { - ResolvedManualSkill, - ResolvedAlwaysApplySkill, - TListSkillsByAccess, - TGetSkillByName, -} from './skills'; import type { ServerRequest, + RequestBody, EndpointDbMethods, EndpointTokenConfig, InitializeResultBase, } from '~/types'; +import type { + ResolvedManualSkill, + ResolvedAlwaysApplySkill, + TListSkillsByAccess, + TGetSkillByName, +} from './skills'; import type { LCAvailableTools, RequestScopedMCPConnectionStore } from '../mcp/types'; import type { TFilterFilesByAgentAccess } from './resources'; +import type { MCPToolAlias } from '~/tools/classification'; import { injectSkillCatalog, resolveManualSkills, @@ -279,6 +281,8 @@ export type InitializedAgent = Agent & { requestScopedConnections?: RequestScopedMCPConnectionStore; /** Serializable tool definitions for event-driven execution */ toolDefinitions?: LCTool[]; + /** Both-direction identity aliases for MCP tools whose key spelling changed */ + mcpToolAliases?: MCPToolAlias[]; /** Precomputed flag indicating if any tools have defer_loading enabled (for efficient runtime checks) */ hasDeferredTools?: boolean; /** @@ -414,6 +418,8 @@ export interface InitializeAgentParams { conversationId?: string | null; /** Parent message ID for determining the current thread (optional) */ parentMessageId?: string | null; + /** Normalized body used by MCP runtime placeholders during tool discovery. */ + requestBody?: RequestBody; /** Request files */ requestFiles?: IMongoFile[]; /** Function to load agent tools */ @@ -426,6 +432,7 @@ export interface InitializeAgentParams { model: string | null; tool_options: AgentToolOptions | undefined; tool_resources: AgentToolResources | undefined; + requestBody?: RequestBody; /** Trusted endpoint/profile resolved for this agent before any code-file priming. */ codeExecutionContext: CodeExecutionContext; /** Full accessible MCP server names (operator + user DB) when the heal @@ -444,6 +451,7 @@ export interface InitializeAgentParams { /** Serializable tool definitions for event-driven mode */ toolDefinitions?: LCTool[]; hasDeferredTools?: boolean; + mcpToolAliases?: MCPToolAlias[]; actionsEnabled?: boolean; /** * Pre-uploaded code-env file refs for the agent's @@ -609,6 +617,7 @@ export async function initializeAgent( conversationId, endpointOption, parentMessageId, + requestBody, allowedProviders, isInitialAgent = false, } = params; @@ -1064,6 +1073,7 @@ export async function initializeAgent( model: agent.model, tool_options: agent.tool_options, tool_resources, + requestBody, codeExecutionContext, accessibleMcpServerNames: resolvedAuditNames, }); @@ -1106,6 +1116,7 @@ export async function initializeAgent( mcpAvailableTools, requestScopedConnections, hasDeferredTools, + mcpToolAliases, actionsEnabled, tools: structuredTools, primedCodeFiles, @@ -1119,6 +1130,7 @@ export async function initializeAgent( requestScopedConnections: undefined, toolDefinitions: [], hasDeferredTools: false, + mcpToolAliases: [], actionsEnabled: undefined, primedCodeFiles: undefined, }; @@ -1557,6 +1569,7 @@ export async function initializeAgent( userMCPAuthMap, toolDefinitions, hasDeferredTools, + mcpToolAliases, backgroundToolNames, intentToolNames, actionsEnabled, diff --git a/packages/api/src/agents/openai/service.spec.ts b/packages/api/src/agents/openai/service.spec.ts index 75bcf091724..bc00e0e3bc1 100644 --- a/packages/api/src/agents/openai/service.spec.ts +++ b/packages/api/src/agents/openai/service.spec.ts @@ -1,3 +1,4 @@ +import { GraphEvents } from '@librechat/agents'; import { ErrorTypes } from 'librechat-data-provider'; import type { ChatCompletionDependencies } from './service'; import { createAgentChatCompletion } from './service'; @@ -15,6 +16,7 @@ type CreateRunArgs = { user?: Record; tenantId?: string; appConfig?: Record; + requestBody?: Record; }; type ProcessStreamConfig = { configurable?: Record }; @@ -110,6 +112,88 @@ describe('createAgentChatCompletion - MCP permission user propagation', () => { expect(streamConfig.configurable?.user).not.toHaveProperty('role'); }); + it('threads the parent message id into the run and execution context', async () => { + const req = createMockReq({ id: 'user-123', role: 'USER' }) as unknown as { + body: Record; + }; + req.body.parent_message_id = 'parent-123'; + + await createAgentChatCompletion(req as never, createMockRes(), deps); + + expect(deps.initializeAgent).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: expect.objectContaining({ parentMessageId: 'parent-123' }), + }), + ); + const runArgs = createRun.mock.calls[0][0] as CreateRunArgs; + expect(runArgs.requestBody).toEqual(expect.objectContaining({ parentMessageId: 'parent-123' })); + const streamConfig = processStream.mock.calls[0][1] as ProcessStreamConfig; + expect(streamConfig.configurable?.requestBody).toEqual(runArgs.requestBody); + }); + + it('forwards the normalized MCP body to deferred execution loaders', async () => { + const req = createMockReq({ id: 'user-123', role: 'USER' }) as unknown as { + body: Record; + }; + req.body.stream = true; + req.body.parent_message_id = 'parent-123'; + const loadTools = jest.fn().mockResolvedValue({ loadedTools: [] }); + deps.toolExecuteOptions = { loadTools }; + + await createAgentChatCompletion(req as never, createMockRes(), deps); + + const runArgs = createRun.mock.calls[0][0] as CreateRunArgs & { + customHandlers: Record Promise }>; + }; + const streamConfig = processStream.mock.calls[0][1] as ProcessStreamConfig; + const resolve = jest.fn(); + const reject = jest.fn(); + await runArgs.customHandlers[GraphEvents.ON_TOOL_EXECUTE].handle(GraphEvents.ON_TOOL_EXECUTE, { + toolCalls: [{ id: 'tool-call-1', name: 'deferred_mcp_tool', args: {} }], + agentId: 'agent_test', + configurable: streamConfig.configurable, + metadata: {}, + resolve, + reject, + }); + + expect(loadTools).toHaveBeenCalledWith( + ['deferred_mcp_tool'], + 'agent_test', + expect.objectContaining({ requestBody: runArgs.requestBody }), + ); + }); + + it('uses the root parent sentinel when chat completions omit a parent id', async () => { + const req = createMockReq({ id: 'user-123', role: 'USER' }); + + await createAgentChatCompletion(req, createMockRes(), deps); + + expect(deps.initializeAgent).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: expect.objectContaining({ + parentMessageId: '00000000-0000-0000-0000-000000000000', + }), + }), + ); + }); + + it('omits an unavailable parent for an existing chat-completions conversation', async () => { + const req = createMockReq({ id: 'user-123', role: 'USER' }) as unknown as { + body: Record; + }; + req.body.conversation_id = 'conversation-123'; + + await createAgentChatCompletion(req as never, createMockRes(), deps); + + const requestBody = (deps.initializeAgent as jest.Mock).mock.calls[0][0].requestBody; + expect(requestBody).toEqual({ + messageId: expect.any(String), + conversationId: 'conversation-123', + }); + expect(requestBody).not.toHaveProperty('parentMessageId'); + }); + it('forwards appConfig and tenantId to createRun', async () => { const appConfig = { endpoints: { diff --git a/packages/api/src/agents/openai/service.ts b/packages/api/src/agents/openai/service.ts index 193e8fa92fc..62b7d8df8e8 100644 --- a/packages/api/src/agents/openai/service.ts +++ b/packages/api/src/agents/openai/service.ts @@ -32,6 +32,7 @@ import type { ToolCall, } from './types'; import type { OpenAIStreamHandlerConfig, EventHandler } from './handlers'; +import type { MCPRuntimeRequestBody } from '~/mcp/request'; import type { ToolExecuteOptions } from '../handlers'; import { createOpenAIContentAggregator, @@ -41,6 +42,7 @@ import { createChunk, writeSSE, } from './handlers'; +import { createMCPRuntimeRequestBody } from '~/mcp/request'; import { createSafeUser } from '~/utils'; /** @@ -135,6 +137,7 @@ interface InitializeAgentParams { agent: Agent; conversationId?: string | null; parentMessageId?: string | null; + requestBody?: MCPRuntimeRequestBody; requestFiles?: unknown[]; loadTools?: LoadToolsFn; endpointOption?: Record; @@ -191,6 +194,7 @@ type LoadToolsFn = (params: { model: string | null; tool_options: unknown; tool_resources: unknown; + requestBody?: MCPRuntimeRequestBody; }) => Promise<{ tools: unknown[]; toolContextMap: Record; @@ -435,6 +439,17 @@ export async function createAgentChatCompletion( // Generate IDs const requestId = `chatcmpl-${nanoid()}`; const conversationId = request.conversation_id ?? nanoid(); + let mcpParentMessageId: string | null | undefined; + if (typeof request.parent_message_id === 'string' && request.parent_message_id.trim() !== '') { + mcpParentMessageId = request.parent_message_id; + } else if (request.conversation_id == null) { + mcpParentMessageId = null; + } + const mcpRequestBody = createMCPRuntimeRequestBody({ + messageId: requestId, + conversationId, + parentMessageId: mcpParentMessageId, + }); const created = Math.floor(Date.now() / 1000); // Build response context @@ -502,6 +517,7 @@ export async function createAgentChatCompletion( agent, conversationId, parentMessageId: request.parent_message_id, + requestBody: mcpRequestBody, loadTools: deps.loadAgentTools, endpointOption: { endpoint: agent.provider, @@ -570,17 +586,13 @@ export async function createAgentChatCompletion( * correctly leaves MCP gated. */ const safeUser: Record = { ...createSafeUser(reqUser), id: userId }; - const run = await deps.createRun({ agents: [initializedAgent], messages, runId: requestId, signal: abortController.signal, customHandlers: eventHandlers, - requestBody: { - messageId: requestId, - conversationId, - }, + requestBody: mcpRequestBody, user: safeUser, tenantId: typeof reqUser?.tenantId === 'string' ? reqUser.tenantId : undefined, appConfig: deps.appConfig @@ -600,6 +612,7 @@ export async function createAgentChatCompletion( thread_id: conversationId, user_id: userId, user: safeUser, + requestBody: mcpRequestBody, /** Same per-agent channel the in-repo controllers thread via * `loadTools`: without it, the executor's PTC path cannot * strip host-injected `intent` params from the schemas the diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index e198b13095f..8e7b9400849 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -41,6 +41,7 @@ import type { BaseMessage } from '@librechat/agents/langchain/messages'; import type { AppConfig, IUser } from '@librechat/data-schemas'; import type { ToolInputValidationError } from '~/agents/toolValidation'; import type { ResolvedAlwaysApplySkill } from '~/agents/skills'; +import type { MCPToolAlias } from '~/tools/classification'; import type { SubagentUsageEvent } from '~/agents/usage'; import type * as t from '~/types'; import { @@ -49,6 +50,11 @@ import { stripBackgroundFromToolRegistry, stripBackgroundFromToolDefinitions, } from '~/agents/background'; +import { + resolveToolApprovalPolicy, + healToolApprovalPolicy, + exemptAskUserQuestionFromApproval, +} from '~/agents/hitl/policy'; import { ASK_USER_QUESTION_TOOL_NAME, createAskUserQuestionTool, @@ -57,7 +63,6 @@ import { createSubagentWakeupHandleHook, usesSubagentCompletionWakeups, } from '~/agents/subagentDelivery'; -import { resolveToolApprovalPolicy, exemptAskUserQuestionFromApproval } from '~/agents/hitl/policy'; import { applyCustomHandoffPromptKeyCompatibility } from '~/agents/handoffPromptKeyCompatibility'; import { stripIntentFromToolRegistry, stripIntentFromToolDefinitions } from '~/agents/intent'; import { isSteeringSupported, isSteerPreemptSupported } from '~/agents/steering/runtime'; @@ -378,6 +383,8 @@ type RunAgent = Omit & { toolDefinitions?: LCTool[]; /** Precomputed flag indicating if any tools have defer_loading enabled */ hasDeferredTools?: boolean; + /** Both-direction identity aliases for MCP tools whose key spelling changed */ + mcpToolAliases?: MCPToolAlias[]; /** Names of tools injected with the `run_in_background` param (excluded from eager execution). */ backgroundToolNames?: string[]; /** Names of tools with the host-injected `intent` param (stripped from self-spawn inputs). */ @@ -1694,18 +1701,29 @@ export async function createRun({ // would pause with no approval surface or resume endpoint, and the route would emit a // normal final response / `[DONE]` with the tool call dangling. Only AgentClient (chat + // resume) passes `hitlCapable`; without it the run is identical to the no-HITL path. + /** Both-direction key-spelling aliases collected at tool classification — + * identical in instance and event-driven loading modes. */ + const mcpToolAliases = agents.flatMap((agent) => agent.mcpToolAliases ?? []); const hitl = hitlCapable ? buildHITLRunWiring( // The ask tool is exempt from the approval prompt (unless explicitly // listed by the admin) — approving the right to ask a question is a - // pure double-pause; the tool has no side effects to gate. - exemptAskUserQuestionFromApproval(toolApprovalPolicy, ASK_USER_QUESTION_TOOL_NAME), + // pure double-pause; the tool has no side effects to gate. Pattern + // lists are healed against the tools' other key spellings first, so + // admin globs written for pre-strip upstream names keep applying (a + // non-matching deny would fail OPEN), and rules written against + // current catalog names reach legacy-named instances. + exemptAskUserQuestionFromApproval( + healToolApprovalPolicy(toolApprovalPolicy, mcpToolAliases), + ASK_USER_QUESTION_TOOL_NAME, + ), { userId: user?.id, conversationId: requestBody?.conversationId, tenantId: tenantId ?? user?.tenantId, appConfig, }, + mcpToolAliases, ) : undefined; /** diff --git a/packages/api/src/agents/view.spec.ts b/packages/api/src/agents/view.spec.ts index dd3733288e8..9849af90e4a 100644 --- a/packages/api/src/agents/view.spec.ts +++ b/packages/api/src/agents/view.spec.ts @@ -5,6 +5,7 @@ import { createSubagentThreadViewHandler, SUBAGENT_THREAD_VIEW_LIMITS } from './ jest.mock('@librechat/data-schemas', () => ({ CLIENT_MESSAGE_SELECT: '-_id -user', + SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT: 256 * 1024, logger: { error: jest.fn() }, })); @@ -70,9 +71,13 @@ const createResponse = () => { }; }; -const createRequest = (params: Record = {}): ServerRequest => +const createRequest = ( + params: Record = {}, + query: Record = {}, +): ServerRequest => ({ params: { parentConversationId, threadId, ...params }, + query, user: { id: 'user-1', tenantId: 'tenant-1' }, }) as ServerRequest; @@ -115,6 +120,8 @@ describe('subagent thread parent-scoped view', () => { agentId: 'agent-1', title: 'Research child', status: 'completed', + activity: [], + activityTruncated: false, messages: [ expect.objectContaining({ messageId: 'task-1:user', role: 'user' }), expect.objectContaining({ @@ -135,6 +142,152 @@ describe('subagent thread parent-scoped view', () => { expect(json.mock.calls[0][0].messages[1]).not.toHaveProperty('subagentTask'); }); + it("returns only the selected task's sanitized bounded activity", async () => { + const selected = { + ...message('task-1:assistant', 'completed'), + subagentTranscript: { + taskId: 'task-1', + mode: 'append' as const, + messagesJson: JSON.stringify([ + { + type: 'ai', + data: { + content: [{ type: 'reasoning', reasoning: 'private thought' }], + tool_calls: [{ id: 'inner-1', name: 'search', args: { query: 'release' } }], + response_metadata: { private: true }, + }, + }, + { + type: 'tool', + data: { + tool_call_id: 'inner-1', + name: 'search', + content: 'Found it.', + }, + }, + { type: 'ai', data: { content: 'Final answer.' } }, + ]), + }, + } as IMessage; + const getMessages = jest.fn().mockResolvedValue([selected]); + const handler = createSubagentThreadViewHandler({ + getConvoOwnership: jest.fn().mockResolvedValue(parent), + getSubagentThreadForParent: jest + .fn() + .mockResolvedValue({ ...child, subagentThreadLease: undefined }), + getMessagesForSubagentThreadView: getMessages, + }); + const { response, json } = createResponse(); + + await handler(createRequest({}, { taskId: 'task-1' }), response); + + expect(getMessages).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'task-1' })); + const view = json.mock.calls[0][0]; + expect(view.activity).toEqual([ + { type: 'reasoning' }, + expect.objectContaining({ + type: 'tool', + toolCallId: 'inner-1', + status: 'completed', + output: 'Found it.', + }), + { type: 'writing', text: 'Final answer.' }, + ]); + expect(JSON.stringify(view)).not.toContain('private thought'); + expect(JSON.stringify(view)).not.toContain('response_metadata'); + expect(view.messages[0]).not.toHaveProperty('subagentTranscript'); + }); + + it('fences replacement activity to the exact selected task input', async () => { + const selected = { + ...message('task-1:assistant', 'completed'), + subagentTranscript: { + taskId: 'task-1', + mode: 'replace' as const, + messagesJson: JSON.stringify([ + { type: 'human', data: { content: 'Earlier request.' } }, + { type: 'ai', data: { content: 'Earlier activity.' } }, + { type: 'human', data: { content: 'Investigate this.' } }, + { type: 'ai', data: { content: 'Selected activity.' } }, + ]), + }, + } as IMessage; + const handler = createSubagentThreadViewHandler({ + getConvoOwnership: jest.fn().mockResolvedValue(parent), + getSubagentThreadForParent: jest + .fn() + .mockResolvedValue({ ...child, subagentThreadLease: undefined }), + getMessagesForSubagentThreadView: jest + .fn() + .mockResolvedValue([selected, message('task-1:user', 'running', true)]), + }); + const { response, json } = createResponse(); + + await handler(createRequest({}, { taskId: 'task-1' }), response); + + expect(json.mock.calls[0][0]).toEqual( + expect.objectContaining({ + activity: [{ type: 'writing', text: 'Selected activity.' }], + activityTruncated: false, + }), + ); + expect(JSON.stringify(json.mock.calls[0][0])).not.toContain('Earlier activity.'); + }); + + it('fails closed when the selected row carries a mismatched transcript identity', async () => { + const selected = { + ...message('task-1:assistant', 'completed'), + subagentTranscript: { + taskId: 'task-other', + mode: 'append' as const, + messagesJson: JSON.stringify([{ type: 'ai', data: { content: 'Wrong task.' } }]), + }, + } as IMessage; + const handler = createSubagentThreadViewHandler({ + getConvoOwnership: jest.fn().mockResolvedValue(parent), + getSubagentThreadForParent: jest + .fn() + .mockResolvedValue({ ...child, subagentThreadLease: undefined }), + getMessagesForSubagentThreadView: jest.fn().mockResolvedValue([selected]), + }); + const { response, json } = createResponse(); + + await handler(createRequest({}, { taskId: 'task-1' }), response); + + expect(json.mock.calls[0][0]).toEqual( + expect.objectContaining({ activity: [], activityTruncated: true }), + ); + expect(JSON.stringify(json.mock.calls[0][0])).not.toContain('Wrong task.'); + }); + + it('falls back to the bounded final message when storage omits an oversized transcript', async () => { + const selected = { + ...message('task-1:assistant', 'completed'), + text: 'The bounded final answer.', + subagentTranscriptProjectionTruncated: true, + } as IMessage & { subagentTranscriptProjectionTruncated: boolean }; + const handler = createSubagentThreadViewHandler({ + getConvoOwnership: jest.fn().mockResolvedValue(parent), + getSubagentThreadForParent: jest + .fn() + .mockResolvedValue({ ...child, subagentThreadLease: undefined }), + getMessagesForSubagentThreadView: jest.fn().mockResolvedValue([selected]), + }); + const { response, json } = createResponse(); + + await handler(createRequest({}, { taskId: 'task-1' }), response); + + const view = json.mock.calls[0][0]; + expect(view).toEqual( + expect.objectContaining({ + activity: [], + activityTruncated: true, + messages: [expect.objectContaining({ text: 'The bounded final answer.' })], + }), + ); + expect(JSON.stringify(view)).not.toContain('subagentTranscript'); + }); + it('bounds the complete UTF-8 response while retaining the newest history', async () => { const getConvoOwnership = jest.fn().mockResolvedValue(parent); const messages = Array.from( @@ -363,6 +516,25 @@ describe('subagent thread parent-scoped view', () => { expect(getMessages).not.toHaveBeenCalled(); }); + it('rejects an oversized task selector before reading storage', async () => { + const getConvoOwnership = jest.fn(); + const getSubagentThreadForParent = jest.fn(); + const getMessages = jest.fn(); + const handler = createSubagentThreadViewHandler({ + getConvoOwnership, + getSubagentThreadForParent, + getMessagesForSubagentThreadView: getMessages, + }); + const { response, status } = createResponse(); + + await handler(createRequest({}, { taskId: 'x'.repeat(513) }), response); + + expect(status).toHaveBeenCalledWith(404); + expect(getConvoOwnership).not.toHaveBeenCalled(); + expect(getSubagentThreadForParent).not.toHaveBeenCalled(); + expect(getMessages).not.toHaveBeenCalled(); + }); + it('marks an unleased running seed as interrupted', async () => { const getConvoOwnership = jest.fn().mockResolvedValue(parent); const handler = createSubagentThreadViewHandler({ diff --git a/packages/api/src/agents/view.ts b/packages/api/src/agents/view.ts index f821de020b3..92503a32407 100644 --- a/packages/api/src/agents/view.ts +++ b/packages/api/src/agents/view.ts @@ -1,4 +1,4 @@ -import { logger } from '@librechat/data-schemas'; +import { logger, SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT } from '@librechat/data-schemas'; import type { ConversationMethods, MessageMethods, @@ -11,6 +11,7 @@ import type { } from 'librechat-data-provider'; import type { Response } from 'express'; import type { ServerRequest } from '~/types'; +import { projectSubagentActivity, SUBAGENT_ACTIVITY_LIMITS } from './activity'; const MAX_THREAD_MESSAGES = 50; const MAX_MESSAGE_TEXT_BYTES = 32 * 1024; @@ -35,6 +36,11 @@ type SubagentThreadViewParams = { const validConversationId = (value: string | undefined): value is string => value != null && value.trim() !== '' && value.length <= 256; +const validTaskId = (value: unknown): value is string => + typeof value === 'string' && + value.trim() !== '' && + Buffer.byteLength(value, 'utf8') <= MAX_PUBLIC_ID_BYTES; + const tenantMatches = (recordTenantId: string | undefined, requestTenantId: string | undefined) => recordTenantId === requestTenantId; @@ -99,8 +105,12 @@ const publicMessage = ( const publicStatus = ( messages: SubagentThreadViewMessageRecord[], activeLeaseTaskId: string | undefined, + requestedTaskId?: string, ): SubagentThreadStatus => { - if (activeLeaseTaskId != null) { + if ( + activeLeaseTaskId != null && + (requestedTaskId == null || requestedTaskId === activeLeaseTaskId) + ) { const activeTaskMessage = messages.find( (message) => message.messageId === `${activeLeaseTaskId}:user` || @@ -114,7 +124,11 @@ const publicStatus = ( } return publicStatus([activeTaskMessage], undefined); } - const message = messages.find((candidate) => candidate.subagentTask != null); + const message = messages.find( + (candidate) => + candidate.subagentTask != null && + (requestedTaskId == null || candidate.messageId.startsWith(`${requestedTaskId}:`)), + ); switch (message?.subagentTask?.status) { case 'running': return 'interrupted'; @@ -139,11 +153,13 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen const userId = req.user?.id; const tenantId = req.user?.tenantId || undefined; const { parentConversationId, threadId } = req.params as SubagentThreadViewParams; + const requestedTaskId = req.query?.taskId; if ( !userId || !validConversationId(parentConversationId) || !validConversationId(threadId) || - parentConversationId === threadId + parentConversationId === threadId || + (requestedTaskId != null && !validTaskId(requestedTaskId)) ) { notFound(res); return; @@ -179,6 +195,7 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen ...(tenantId == null ? {} : { tenantId }), limit: MAX_THREAD_MESSAGES + 1, textCodePointLimit: MAX_MESSAGE_TEXT_PROJECTION_CODE_POINTS, + ...(requestedTaskId == null ? {} : { taskId: requestedTaskId }), }); const historyTruncated = messages.length > MAX_THREAD_MESSAGES; @@ -187,6 +204,30 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen child.subagentThreadLease != null && child.subagentThreadLease.expiresAt > now ? child.subagentThreadLease.taskId : undefined; + const selectedMessage = + requestedTaskId == null + ? undefined + : newestFirst.find((message) => message.messageId === `${requestedTaskId}:assistant`); + const selectedTranscript = selectedMessage?.subagentTranscript; + const selectedInput = + requestedTaskId == null + ? undefined + : newestFirst.find((message) => message.messageId === `${requestedTaskId}:user`); + let projectedActivity: ReturnType = { + activity: [], + truncated: false, + }; + if (selectedMessage?.subagentTranscriptProjectionTruncated === true) { + projectedActivity = { activity: [], truncated: true }; + } else if (selectedTranscript != null && selectedTranscript.taskId === requestedTaskId) { + projectedActivity = projectSubagentActivity( + selectedTranscript.messagesJson, + selectedTranscript.mode, + selectedInput?.textProjectionTruncated === true ? undefined : selectedInput?.text, + ); + } else if (selectedTranscript != null) { + projectedActivity = { activity: [], truncated: true }; + } const projectedNewestFirst: SubagentThreadMessage[] = []; let remainingTextBytes = MAX_RESPONSE_TEXT_BYTES; for (const message of newestFirst) { @@ -209,7 +250,9 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen : { agentId: truncateUtf8(child.agent_id, MAX_PUBLIC_ID_BYTES).text }), title: truncateUtf8(child.title ?? `Subagent: ${lineage.subagentType}`, MAX_TITLE_BYTES) .text, - status: publicStatus(newestFirst, activeLeaseTaskId), + status: publicStatus(newestFirst, activeLeaseTaskId, requestedTaskId), + activity: projectedActivity.activity, + activityTruncated: projectedActivity.truncated, messages: projectedNewestFirst.reverse(), historyTruncated: historyTruncated || projectedNewestFirst.length < newestFirst.length, ...(isoDate(child.updatedAt) == null ? {} : { updatedAt: isoDate(child.updatedAt) }), @@ -234,9 +277,15 @@ export const SUBAGENT_THREAD_VIEW_LIMITS: Readonly<{ messageTextBytes: number; responseTextBytes: number; responseBytes: number; + activityItems: number; + activityBytes: number; + activitySourceBytes: number; }> = { messages: MAX_THREAD_MESSAGES, messageTextBytes: MAX_MESSAGE_TEXT_BYTES, responseTextBytes: MAX_RESPONSE_TEXT_BYTES, responseBytes: MAX_RESPONSE_BYTES, + activityItems: SUBAGENT_ACTIVITY_LIMITS.items, + activityBytes: SUBAGENT_ACTIVITY_LIMITS.bytes, + activitySourceBytes: SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT, }; diff --git a/packages/api/src/mcp/__tests__/request.test.ts b/packages/api/src/mcp/__tests__/request.test.ts index e58c972134b..47cd41c18f6 100644 --- a/packages/api/src/mcp/__tests__/request.test.ts +++ b/packages/api/src/mcp/__tests__/request.test.ts @@ -1,6 +1,11 @@ import { EventEmitter } from 'events'; -import { getMCPRequestContext, cleanupMCPRequestContextForReq } from '~/mcp/request'; +import { + createMCPRuntimeRequestBody, + getMCPRequestContext, + cleanupMCPRequestContextForReq, +} from '~/mcp/request'; +import { getMissingRuntimeBodyPlaceholderFields } from '~/mcp/utils'; jest.mock('@librechat/data-schemas', () => ({ logger: { @@ -105,3 +110,47 @@ describe('MCP request context', () => { expect(connection.disconnect).not.toHaveBeenCalled(); }); }); + +describe('MCP runtime request body', () => { + it('preserves a supplied parent message id', () => { + expect( + createMCPRuntimeRequestBody({ + messageId: 'response-1', + conversationId: 'conversation-1', + parentMessageId: 'parent-1', + }), + ).toEqual({ + messageId: 'response-1', + conversationId: 'conversation-1', + parentMessageId: 'parent-1', + }); + }); + + it('uses the root-turn parent sentinel for an explicit root parent', () => { + expect( + createMCPRuntimeRequestBody({ + messageId: 'response-1', + conversationId: 'conversation-1', + parentMessageId: null, + }), + ).toEqual(expect.objectContaining({ parentMessageId: '00000000-0000-0000-0000-000000000000' })); + }); + + it('leaves the parent absent when the protocol cannot supply that identity', () => { + const requestBody = createMCPRuntimeRequestBody({ + messageId: 'response-1', + conversationId: 'conversation-1', + }); + + expect(requestBody).toEqual({ messageId: 'response-1', conversationId: 'conversation-1' }); + expect( + getMissingRuntimeBodyPlaceholderFields( + { + source: 'yaml', + headers: { 'X-Parent': '{{LIBRECHAT_BODY_PARENTMESSAGEID}}' }, + }, + requestBody, + ), + ).toEqual(['parentMessageId']); + }); +}); diff --git a/packages/api/src/mcp/__tests__/scope.integration.test.ts b/packages/api/src/mcp/__tests__/scope.integration.test.ts index 9f49d161de2..59fe600f6f6 100644 --- a/packages/api/src/mcp/__tests__/scope.integration.test.ts +++ b/packages/api/src/mcp/__tests__/scope.integration.test.ts @@ -51,6 +51,7 @@ interface RequestScopedTestServer { sessionsCreated: () => number; toolCallCount: () => number; observedRunIds: () => string[]; + observedParentMessageIds: () => string[]; } function trackSockets(httpServer: http.Server): () => Promise { @@ -72,6 +73,7 @@ function trackSockets(httpServer: http.Server): () => Promise { async function createRequestScopedTestServer(): Promise { const sessions = new Map(); const runIds: string[] = []; + const parentMessageIds: string[] = []; let created = 0; let deletes = 0; let toolCalls = 0; @@ -87,6 +89,10 @@ async function createRequestScopedTestServer(): Promise if (typeof runId === 'string') { runIds.push(runId); } + const parentMessageId = req.headers['x-parent-message']; + if (typeof parentMessageId === 'string') { + parentMessageIds.push(parentMessageId); + } } else if (req.method === 'DELETE') { deletes += 1; } @@ -127,6 +133,7 @@ async function createRequestScopedTestServer(): Promise sessionsCreated: () => created, toolCallCount: () => toolCalls, observedRunIds: () => [...runIds], + observedParentMessageIds: () => [...parentMessageIds], close: async () => { const closing = [...sessions.values()].map((transport) => transport.close().catch(() => undefined), @@ -157,7 +164,10 @@ function createServerConfig(url: string): ParsedServerConfig { source: 'yaml', requiresOAuth: false, initTimeout: 500, - headers: { 'X-Run-Id': '{{LIBRECHAT_BODY_MESSAGEID}}' }, + headers: { + 'X-Run-Id': '{{LIBRECHAT_BODY_MESSAGEID}}', + 'X-Parent-Message': '{{LIBRECHAT_BODY_PARENTMESSAGEID}}', + }, }; } @@ -246,6 +256,7 @@ describe('request-scoped MCP lifecycle integration', () => { expect(server.liveSessionCount()).toBe(1); expect(server.toolCallCount()).toBe(burstSize); expect(new Set(server.observedRunIds())).toEqual(new Set(['run-1'])); + expect(new Set(server.observedParentMessageIds())).toEqual(new Set(['parent-1'])); expect(manager.getConnectionStats().activityEntries).toBe(0); await cleanupMCPRequestContext(firstRun); @@ -266,6 +277,7 @@ describe('request-scoped MCP lifecycle integration', () => { expect(server.sessionsCreated()).toBe(2); expect(server.liveSessionCount()).toBe(1); expect(new Set(server.observedRunIds())).toEqual(new Set(['run-1', 'run-2'])); + expect(new Set(server.observedParentMessageIds())).toEqual(new Set(['parent-1'])); }); it('clears a failed run so the same server can recover in a fresh run', async () => { diff --git a/packages/api/src/mcp/__tests__/utils.test.ts b/packages/api/src/mcp/__tests__/utils.test.ts index 0f85636cc46..53cb788f29e 100644 --- a/packages/api/src/mcp/__tests__/utils.test.ts +++ b/packages/api/src/mcp/__tests__/utils.test.ts @@ -13,7 +13,7 @@ import { getMissingCustomUserVars, hasCustomUserVars, hasRuntimeUrlPlaceholders, - hasRuntimeBodyPlaceholders, + getMCPRequestScope, hasRuntimeContextPlaceholders, getRuntimeBodyPlaceholderFields, getMissingRuntimeBodyPlaceholderFields, @@ -814,32 +814,32 @@ describe('hasRuntimeUrlPlaceholders', () => { }); }); -describe('hasRuntimeBodyPlaceholders', () => { +describe('getMCPRequestScope', () => { it('detects trusted runtime BODY placeholders across connection fields', () => { expect( - hasRuntimeBodyPlaceholders({ + getMCPRequestScope({ source: 'yaml', url: 'https://example.com/conversations/{{LIBRECHAT_BODY_CONVERSATIONID}}/mcp', - }), + }).requestScoped, ).toBe(true); expect( - hasRuntimeBodyPlaceholders({ + getMCPRequestScope({ source: 'config', headers: { 'X-Message': '{{LIBRECHAT_BODY_MESSAGEID}}', }, - }), + }).requestScoped, ).toBe(true); }); it('ignores BODY placeholders in user-sourced configs', () => { expect( - hasRuntimeBodyPlaceholders({ + getMCPRequestScope({ source: 'user', dbId: 'server-123', url: 'https://example.com/{{LIBRECHAT_BODY_MESSAGEID}}/mcp', - }), + }).requestScoped, ).toBe(false); }); @@ -851,7 +851,7 @@ describe('hasRuntimeBodyPlaceholders', () => { expect(hasRuntimeContextPlaceholders(config)).toBe(false); expect(hasRuntimeUrlPlaceholders(config)).toBe(false); - expect(hasRuntimeBodyPlaceholders(config)).toBe(false); + expect(getMCPRequestScope(config).requestScoped).toBe(false); expect(getRuntimeBodyPlaceholderFields(config)).toEqual([]); expect(getMissingRuntimeBodyPlaceholderFields(config)).toEqual([]); expect(requiresEphemeralUserConnection(config)).toBe(false); @@ -869,7 +869,7 @@ describe('hasRuntimeBodyPlaceholders', () => { expect(hasRuntimeContextPlaceholders(config)).toBe(false); expect(hasRuntimeUrlPlaceholders(config)).toBe(false); - expect(hasRuntimeBodyPlaceholders(config)).toBe(false); + expect(getMCPRequestScope(config).requestScoped).toBe(false); expect(getRuntimeBodyPlaceholderFields(config)).toEqual([]); expect(getMissingRuntimeBodyPlaceholderFields(config)).toEqual([]); expect(requiresEphemeralUserConnection(config)).toBe(false); diff --git a/packages/api/src/mcp/assistants.spec.ts b/packages/api/src/mcp/assistants.spec.ts index a3269114040..38eafdd2047 100644 --- a/packages/api/src/mcp/assistants.spec.ts +++ b/packages/api/src/mcp/assistants.spec.ts @@ -1,7 +1,7 @@ import { Constants } from 'librechat-data-provider'; import type { LCAvailableTools, ParsedServerConfig } from './types'; import type { AssistantToolDefinitionsDeps } from './assistants'; -import { getAssistantToolDefinitions } from './assistants'; +import { getAssistantToolDefinitions, toProviderToolDefinition } from './assistants'; const serverConfig: ParsedServerConfig = { type: 'streamable-http', @@ -48,12 +48,47 @@ describe('getAssistantToolDefinitions', () => { const deps = createDeps(); await expect(getAssistantToolDefinitions(params, deps)).resolves.toEqual({ - ...params.staticTools, - ...catalog, + toolDefinitions: { ...params.staticTools, ...catalog }, + accessibleServerNames: ['app-server'], }); expect(deps.getMCPServerTools).toHaveBeenCalledWith('user-1', 'app-server', serverConfig); }); + it('retains serverToolName for the heal; toProviderToolDefinition strips it at submission', async () => { + /** The heal verifies legacy rewrites against the recorded upstream + * identity, so the loader keeps the field; assistant writers submit + * entries verbatim, so the controllers sanitize each entry through + * toProviderToolDefinition before the provider sees it. */ + const strippedKey = `search${Constants.mcp_delimiter}app-server`; + const strippedCatalog: LCAvailableTools = { + [strippedKey]: { + type: 'function', + serverToolName: 'app-server_search', + ['function']: { + name: strippedKey, + description: '', + parameters: { type: 'object', properties: {} }, + }, + }, + }; + const deps = createDeps({ getMCPServerTools: jest.fn().mockResolvedValue(strippedCatalog) }); + + const { toolDefinitions } = await getAssistantToolDefinitions(params, deps); + + expect(toolDefinitions[strippedKey]?.serverToolName).toBe('app-server_search'); + + const sanitized = toProviderToolDefinition(toolDefinitions[strippedKey]); + expect(sanitized).toEqual({ + type: 'function', + ['function']: strippedCatalog[strippedKey]['function'], + }); + expect(sanitized).not.toHaveProperty('serverToolName'); + expect(toProviderToolDefinition('code_interpreter')).toBe('code_interpreter'); + expect(toProviderToolDefinition(params.staticTools.code_interpreter)).toBe( + params.staticTools.code_interpreter, + ); + }); + it('reconnects a user server when neither cache nor local snapshot has a catalog', async () => { const recoveredCatalog = { ...catalog }; const recoverServerTools = jest.fn().mockResolvedValue(recoveredCatalog); @@ -64,8 +99,8 @@ describe('getAssistantToolDefinitions', () => { }); await expect(getAssistantToolDefinitions(params, deps)).resolves.toEqual({ - ...params.staticTools, - ...recoveredCatalog, + toolDefinitions: { ...params.staticTools, ...recoveredCatalog }, + accessibleServerNames: ['app-server'], }); expect(recoverServerTools).toHaveBeenCalledWith('app-server', serverConfig); }); @@ -118,7 +153,10 @@ describe('getAssistantToolDefinitions', () => { cacheMCPServerTools, }); - await expect(getAssistantToolDefinitions(params, deps)).resolves.toEqual(params.staticTools); + await expect(getAssistantToolDefinitions(params, deps)).resolves.toEqual({ + toolDefinitions: params.staticTools, + accessibleServerNames: ['app-server'], + }); expect(cacheMCPServerTools).toHaveBeenCalledWith({ userId: 'user-1', serverName: 'app-server', @@ -192,7 +230,7 @@ describe('getAssistantToolDefinitions', () => { }, deps, ), - ).resolves.toBe(staticTools); + ).resolves.toEqual({ toolDefinitions: staticTools }); expect(deps.ensureConfigServers).not.toHaveBeenCalled(); expect(deps.getMCPServerTools).not.toHaveBeenCalled(); }); diff --git a/packages/api/src/mcp/assistants.ts b/packages/api/src/mcp/assistants.ts index 6b0160ce4ac..78906210b45 100644 --- a/packages/api/src/mcp/assistants.ts +++ b/packages/api/src/mcp/assistants.ts @@ -6,7 +6,7 @@ import { splitMCPToolKey, } from 'librechat-data-provider'; import type { MCPOptions } from 'librechat-data-provider'; -import type { LCAvailableTools, ParsedServerConfig } from '~/mcp/types'; +import type { LCAvailableTools, LCFunctionTool, ParsedServerConfig } from '~/mcp/types'; import { createConcurrencyLimiter } from '~/utils/promise'; import { findShadowedServerNames } from '~/mcp/utils'; @@ -154,11 +154,22 @@ async function loadServerCatalog( throw new Error(`MCP tool definitions unavailable for assistant server "${serverName}"`); } +export interface AssistantToolDefinitionsResult { + toolDefinitions: LCAvailableTools; + /** + * Every server name the principal can reach, from the same merged registry + * read that resolved the catalogs — the legacy-key heal reuses it instead + * of repeating the app-config and registry round trips on the write path. + * `undefined` when the payload references no MCP tools (nothing to heal). + */ + accessibleServerNames?: string[]; +} + /** Loads the static catalog with the configuration-addressed MCP slices referenced by an assistant. */ export async function getAssistantToolDefinitions( params: AssistantToolDefinitionsParams, deps: AssistantToolDefinitionsDeps, -): Promise { +): Promise { const mcpToolNames = params.tools?.filter( (tool): tool is string => @@ -166,7 +177,7 @@ export async function getAssistantToolDefinitions( ) ?? []; const userId = params.user?.id; if (mcpToolNames.length === 0 || !userId) { - return params.staticTools; + return { toolDefinitions: params.staticTools }; } const configs = await resolveAssistantMcpConfigs( @@ -182,5 +193,31 @@ export async function getAssistantToolDefinitions( (serverName) => loadServerCatalog(userId, serverName, configs[serverName], deps, recover), ), ); - return Object.assign({}, params.staticTools, ...serverCatalogs); + /** Entries keep `serverToolName` here: the assistants heal verifies legacy + * key rewrites against that upstream identity. The controllers sanitize + * through {@link toProviderToolDefinition} at the submission boundary. */ + return { + toolDefinitions: Object.assign({}, params.staticTools, ...serverCatalogs), + accessibleServerNames: [ + ...new Set([...Object.keys(configs), ...Object.keys(params.mcpConfig)]), + ], + }; +} + +/** + * Assistant writers submit tool entries VERBATIM as provider tool definitions + * (`assistantData.tools` in the v1/v2 controllers), and providers reject + * unknown fields — the internal `serverToolName` mapping must never leave the + * catalog. Strings and entries without the mapping pass through by reference; + * the cached catalog keeps the mapping for the runtime call path. + */ +export function toProviderToolDefinition(tool: T): T | LCFunctionTool { + if (tool == null || typeof tool !== 'object') { + return tool; + } + const entry = tool as Partial; + if (entry.serverToolName == null || entry.type !== 'function' || entry['function'] == null) { + return tool; + } + return { type: entry.type, ['function']: entry['function'] }; } diff --git a/packages/api/src/mcp/catalog/store.ts b/packages/api/src/mcp/catalog/store.ts index 96cae3c75c5..574a71f4ad1 100644 --- a/packages/api/src/mcp/catalog/store.ts +++ b/packages/api/src/mcp/catalog/store.ts @@ -116,14 +116,23 @@ redis.call('DEL', KEYS[2]) return 1 `; +/** + * Catalog entries can carry `serverToolName` (redundant server-name prefix + * stripping): an older replica reading a stripped entry ignores the mapping + * and calls the stripped key segment upstream. Versioning the MCP catalog + * slices keeps mixed-version replicas on their own representation during a + * rolling deploy; stale slices simply expire. + */ +const CATALOG_VERSION = 'v2'; + export const ToolCacheKeys = { GLOBAL: 'tools:global', MCP_APP_SERVER: (serverName: string, configGeneration: string): string => - `tools:mcp:app:${encodeURIComponent(serverName)}:${encodeURIComponent(configGeneration)}`, + `tools:mcp:app:${CATALOG_VERSION}:${encodeURIComponent(serverName)}:${encodeURIComponent(configGeneration)}`, MCP_SERVER: (userId: string, serverName: string, configGeneration?: string): string => configGeneration - ? `tools:mcp:user:{${encodeURIComponent(userId)}:${encodeURIComponent(serverName)}}:${encodeURIComponent(configGeneration)}` - : `tools:mcp:${userId}:${serverName}`, + ? `tools:mcp:user:{${encodeURIComponent(userId)}:${encodeURIComponent(serverName)}}:${CATALOG_VERSION}:${encodeURIComponent(configGeneration)}` + : `tools:mcp:${CATALOG_VERSION}:${userId}:${serverName}`, MCP_SERVER_GENERATION: (userId: string, serverName: string): string => `tools:metadata:mcp:user-generation:{${encodeURIComponent(userId)}:${encodeURIComponent(serverName)}}`, MCP_SERVER_LEGACY_FENCE: (userId: string, serverName: string): string => diff --git a/packages/api/src/mcp/registry/MCPServerInspector.ts b/packages/api/src/mcp/registry/MCPServerInspector.ts index b0c1b436e0b..fe05d41e6cf 100644 --- a/packages/api/src/mcp/registry/MCPServerInspector.ts +++ b/packages/api/src/mcp/registry/MCPServerInspector.ts @@ -1,5 +1,5 @@ import { logger } from '@librechat/data-schemas'; -import { Constants, normalizeServerName } from 'librechat-data-provider'; +import { Constants, normalizeServerName, stripServerNamePrefixes } from 'librechat-data-provider'; import type { JsonSchemaType } from '@librechat/data-schemas'; import type { MCPConnection } from '~/mcp/connection'; import type * as t from '~/mcp/types'; @@ -188,10 +188,16 @@ export class MCPServerInspector { /** Model-facing key: must match the runtime instance name, which embeds * the normalized server name (see `createToolInstance` in MCP.js). */ const keyServerName = normalizeServerName(serverName); + const keyToolNames = stripServerNamePrefixes( + tools.map((tool) => tool.name), + keyServerName, + ); tools.forEach((tool) => { - const name = `${tool.name}${Constants.mcp_delimiter}${keyServerName}`; + const keyToolName = keyToolNames.get(tool.name) ?? tool.name; + const name = `${keyToolName}${Constants.mcp_delimiter}${keyServerName}`; toolFunctions[name] = { type: 'function', + ...(keyToolName !== tool.name && { serverToolName: tool.name }), ['function']: { name, description: tool.description, diff --git a/packages/api/src/mcp/registry/MCPServersInitializer.ts b/packages/api/src/mcp/registry/MCPServersInitializer.ts index 4cdd148383a..143d14c699b 100644 --- a/packages/api/src/mcp/registry/MCPServersInitializer.ts +++ b/packages/api/src/mcp/registry/MCPServersInitializer.ts @@ -22,9 +22,13 @@ const DEFAULT_FOLLOWER_RETRY_MS = 3000; * followers short-circuit on the stale status and never re-tag entries written * by the previous version. Bumped to 3 so cached entries whose `serverInstructions` still holds * inspector-fetched text are rewritten with the declaration preserved and the text moved to - * `resolvedInstructions`. + * `resolvedInstructions`. Bumped to 4 so persisted `toolFunctions` are rebuilt + * with redundant server-name prefixes stripped and `serverToolName` recorded — + * otherwise a follower accepts the previous deployment's config hash and + * republishes pre-strip definitions into the current catalog namespace + * indefinitely. */ -const REGISTRY_STORAGE_SCHEMA_VERSION = 3; +const REGISTRY_STORAGE_SCHEMA_VERSION = 4; const parseDurationMs = ( value: string | undefined, diff --git a/packages/api/src/mcp/registry/__tests__/MCPServerInspector.test.ts b/packages/api/src/mcp/registry/__tests__/MCPServerInspector.test.ts index 52990606991..c77b1b32e7d 100644 --- a/packages/api/src/mcp/registry/__tests__/MCPServerInspector.test.ts +++ b/packages/api/src/mcp/registry/__tests__/MCPServerInspector.test.ts @@ -597,6 +597,33 @@ describe('MCPServerInspector', () => { expect(result[key]['function'].name).toBe(key); }); + it('strips a redundant server-name prefix from keys and records the raw name', async () => { + mockConnection.fetchOrderedToolsSnapshot = jest.fn().mockResolvedValue({ + complete: true, + tools: [ + { + name: 'acme_trace_top_time_consuming_operations', + description: 'Trace', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'list_services', + description: 'List', + inputSchema: { type: 'object', properties: {} }, + }, + ], + }); + + const { tools: result } = await MCPServerInspector.getToolCatalog('acme', mockConnection); + + const strippedKey = 'trace_top_time_consuming_operations_mcp_acme'; + const plainKey = 'list_services_mcp_acme'; + expect(Object.keys(result).sort()).toEqual([plainKey, strippedKey].sort()); + expect(result[strippedKey]['function'].name).toBe(strippedKey); + expect(result[strippedKey].serverToolName).toBe('acme_trace_top_time_consuming_operations'); + expect(result[plainKey].serverToolName).toBeUndefined(); + }); + it('rejects an incomplete snapshot before it can replace cached tools', async () => { mockConnection.fetchOrderedToolsSnapshot = jest.fn().mockResolvedValue({ tools: [{ name: 'partial', inputSchema: { type: 'object' } }], diff --git a/packages/api/src/mcp/request.ts b/packages/api/src/mcp/request.ts index 68b76027f01..4c2277181fc 100644 --- a/packages/api/src/mcp/request.ts +++ b/packages/api/src/mcp/request.ts @@ -1,6 +1,33 @@ import { logger } from '@librechat/data-schemas'; - -import type { RequestScopedMCPConnectionStore } from './types'; +import { Constants } from 'librechat-data-provider'; + +import type { MCPRuntimeRequestBody, RequestScopedMCPConnectionStore } from './types'; + +export type { MCPRuntimeRequestBody } from './types'; + +/** + * Builds the complete request context that runtime MCP placeholders may resolve. + * An explicit null parent means a known root turn and becomes the root sentinel. + * An omitted parent stays omitted so protocols without parent-message identity + * fail closed for configurations that require that BODY placeholder. + */ +export function createMCPRuntimeRequestBody({ + messageId, + conversationId, + parentMessageId, +}: { + messageId: string; + conversationId: string; + parentMessageId?: string | null; +}): MCPRuntimeRequestBody { + return { + messageId, + conversationId, + ...(parentMessageId !== undefined && { + parentMessageId: parentMessageId ?? Constants.NO_PARENT, + }), + }; +} export interface MCPRequestContext extends RequestScopedMCPConnectionStore { cleanupStarted: boolean; diff --git a/packages/api/src/mcp/tools.spec.ts b/packages/api/src/mcp/tools.spec.ts index 03726be831a..56fad5f6661 100644 --- a/packages/api/src/mcp/tools.spec.ts +++ b/packages/api/src/mcp/tools.spec.ts @@ -495,6 +495,49 @@ describe('createMCPToolCacheService', () => { }); }); + it('strips a redundant server-name prefix from keys and records the raw name', async () => { + /** `acme_trace..._mcp_acme` carries the server twice and can push the + * model-facing name past provider function-name limits (64). */ + const deps = createMockDeps(); + const tools: MCPToolInput[] = [ + { name: 'acme_trace_top_time_consuming_operations', description: 'Trace' }, + { name: 'list_services', description: 'List' }, + ]; + const result = await createMCPToolCacheService(deps).updateMCPServerTools({ + userId: 'u1', + serverName: 'acme', + tools, + }); + + const strippedKey = toolName('trace_top_time_consuming_operations', 'acme'); + const plainKey = toolName('list_services', 'acme'); + expect(Object.keys(result ?? {}).sort()).toEqual([plainKey, strippedKey].sort()); + expect(result?.[strippedKey]?.['function'].name).toBe(strippedKey); + expect(result?.[strippedKey]?.serverToolName).toBe( + 'acme_trace_top_time_consuming_operations', + ); + expect(result?.[plainKey]?.serverToolName).toBeUndefined(); + }); + + it('keeps the prefixed key when stripping would collide with a sibling tool', async () => { + const deps = createMockDeps(); + const tools: MCPToolInput[] = [ + { name: 'search', description: 'Plain' }, + { name: 'acme_search', description: 'Prefixed' }, + ]; + const result = await createMCPToolCacheService(deps).updateMCPServerTools({ + userId: 'u1', + serverName: 'acme', + tools, + }); + + const plainKey = toolName('search', 'acme'); + const prefixedKey = toolName('acme_search', 'acme'); + expect(Object.keys(result ?? {}).sort()).toEqual([prefixedKey, plainKey].sort()); + expect(result?.[plainKey]?.serverToolName).toBeUndefined(); + expect(result?.[prefixedKey]?.serverToolName).toBeUndefined(); + }); + it('builds request-scoped tools without caching them', async () => { const deps = createMockDeps({ getServerConfig: jest.fn().mockResolvedValue(requestScopedConfig), diff --git a/packages/api/src/mcp/tools.ts b/packages/api/src/mcp/tools.ts index b1b801e1ca8..d043d2cad1f 100644 --- a/packages/api/src/mcp/tools.ts +++ b/packages/api/src/mcp/tools.ts @@ -1,5 +1,10 @@ import { logger } from '@librechat/data-schemas'; -import { Constants, buildServerNameAliases, normalizeServerName } from 'librechat-data-provider'; +import { + Constants, + buildServerNameAliases, + normalizeServerName, + stripServerNamePrefixes, +} from 'librechat-data-provider'; import type { Tool } from '@modelcontextprotocol/sdk/types.js'; import type { JsonSchemaType } from '@librechat/agents'; import type { LCAvailableTools, LCFunctionTool, ParsedServerConfig } from './types'; @@ -234,8 +239,13 @@ export function createMCPToolCacheService(deps: MCPToolCacheDeps): MCPToolCacheS * `normalizeServerName(serverName)`. The cache STORE itself stays keyed * by the raw config name. */ const keyServerName = normalizeServerName(serverName); + const keyToolNames = stripServerNamePrefixes( + tools.map((tool) => tool.name), + keyServerName, + ); for (const tool of tools) { - const name = `${tool.name}${mcpDelimiter}${keyServerName}`; + const keyToolName = keyToolNames.get(tool.name) ?? tool.name; + const name = `${keyToolName}${mcpDelimiter}${keyServerName}`; const entry: LCFunctionTool = { type: 'function', ['function']: { @@ -246,6 +256,9 @@ export function createMCPToolCacheService(deps: MCPToolCacheDeps): MCPToolCacheS : ({ type: 'object', properties: {} } as JsonSchemaType), }, }; + if (keyToolName !== tool.name) { + entry.serverToolName = tool.name; + } serverTools[name] = entry; } diff --git a/packages/api/src/mcp/types/index.ts b/packages/api/src/mcp/types/index.ts index 4585ac1bd60..e823f421b10 100644 --- a/packages/api/src/mcp/types/index.ts +++ b/packages/api/src/mcp/types/index.ts @@ -25,6 +25,9 @@ import type { FlowStateManager } from '~/flow/manager'; import type { RequestBody } from '~/types/http'; import type * as o from '~/mcp/oauth/types'; +export type MCPRuntimeRequestBody = Required> & + Pick; + export type StdioOptions = z.infer; export type WebSocketOptions = z.infer; export type SSEOptions = z.infer; @@ -49,6 +52,9 @@ export interface MCPResource { export interface LCFunctionTool { type: 'function'; ['function']: LCTool; + /** Raw upstream tool name when the model-facing key stripped a redundant + * server-name prefix — tool calls must send THIS name to the server. */ + serverToolName?: string; } export type LCAvailableTools = Record; diff --git a/packages/api/src/mcp/utils.ts b/packages/api/src/mcp/utils.ts index a715a1df09e..ea0d4c04462 100644 --- a/packages/api/src/mcp/utils.ts +++ b/packages/api/src/mcp/utils.ts @@ -202,6 +202,11 @@ type PlaceholderValue = | readonly PlaceholderValue[] | { readonly [key: string]: PlaceholderValue }; +export interface MCPRequestScope { + requestScoped: boolean; + requiredBodyFields: Array; +} + type UserScopedConnectionConfig = Pick< ParsedServerConfig, 'requiresOAuth' | 'source' | 'dbId' | 'startup' @@ -281,7 +286,10 @@ function hasPlaceholder(value: PlaceholderValue, pattern: RegExp): boolean { return Object.values(value).some((item) => hasPlaceholder(item, pattern)); } -function addRuntimeBodyPlaceholderFields(value: PlaceholderValue, fields: Set): void { +function addRuntimeBodyPlaceholderFields( + value: PlaceholderValue, + fields: Set, +): void { if (typeof value === 'string') { for (const match of value.matchAll(RUNTIME_BODY_PLACEHOLDER_CAPTURE_PATTERN)) { const placeholderKey = match[1]; @@ -335,34 +343,32 @@ export function hasRuntimeUrlPlaceholders(config: UserScopedConnectionConfig): b return hasRuntimeContextPlaceholder(config.url); } -export function hasRuntimeBodyPlaceholders(config: UserScopedConnectionConfig): boolean { - if (!canResolveRuntimePlaceholders(config)) { - return false; - } - - return placeholderBearingFields(config).some((value) => - hasPlaceholder(value, RUNTIME_BODY_PLACEHOLDER_PATTERN), - ); -} - -export function getRuntimeBodyPlaceholderFields(config: UserScopedConnectionConfig): string[] { +export function getMCPRequestScope(config: UserScopedConnectionConfig): MCPRequestScope { if (!canResolveRuntimePlaceholders(config)) { - return []; + return { requestScoped: false, requiredBodyFields: [] }; } - const fields = new Set(); + const requiredBodyFields = new Set(); for (const value of placeholderBearingFields(config)) { - addRuntimeBodyPlaceholderFields(value, fields); + addRuntimeBodyPlaceholderFields(value, requiredBodyFields); } - return Array.from(fields); + + const fields = Array.from(requiredBodyFields); + return { requestScoped: fields.length > 0, requiredBodyFields: fields }; +} + +export function getRuntimeBodyPlaceholderFields( + config: UserScopedConnectionConfig, +): Array { + return getMCPRequestScope(config).requiredBodyFields; } export function getMissingRuntimeBodyPlaceholderFields( config: UserScopedConnectionConfig, requestBody?: RequestBody, ): string[] { - return getRuntimeBodyPlaceholderFields(config).filter((field) => { - const value = requestBody?.[field as keyof RequestBody]; + return getMCPRequestScope(config).requiredBodyFields.filter((field) => { + const value = requestBody?.[field]; return value == null || (typeof value === 'string' && value.trim() === ''); }); } @@ -380,13 +386,7 @@ export function getMissingRuntimeBodyPlaceholderFields( * connection without forcing a reconnect for every invocation. */ export function requiresEphemeralUserConnection(config: UserScopedConnectionConfig): boolean { - if (!canResolveRuntimePlaceholders(config)) { - return false; - } - - return placeholderBearingFields(config).some((value) => - hasPlaceholder(value, RUNTIME_BODY_PLACEHOLDER_PATTERN), - ); + return getMCPRequestScope(config).requestScoped; } /** @@ -631,4 +631,6 @@ export { normalizeServerName, normalizeMCPToolKey, buildServerNameAliases, + stripServerNamePrefix, + stripServerNamePrefixes, } from 'librechat-data-provider'; diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index 3a89dfb2d98..710a9fe0433 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -2528,6 +2528,8 @@ class GenerationJobManagerClass { generationProtocolVersion: jobData.generationProtocolVersion, userMessage: jobData.userMessage, responseMessageId: jobData.responseMessageId, + isRegenerate: jobData.isRegenerate, + mcpRequestBody: jobData.mcpRequestBody, sender: jobData.sender, endpoint: jobData.endpoint, iconURL: jobData.iconURL, @@ -6928,6 +6930,7 @@ class GenerationJobManagerClass { aggregatedContent, userMessage: jobData.userMessage, responseMessageId: jobData.responseMessageId, + isRegenerate: jobData.isRegenerate, conversationId: jobData.conversationId, sender: jobData.sender, iconURL: jobData.iconURL, diff --git a/packages/api/src/stream/__tests__/GenerationJobManager.resumeReplay.spec.ts b/packages/api/src/stream/__tests__/GenerationJobManager.resumeReplay.spec.ts index d252af0d828..dee6a9e117d 100644 --- a/packages/api/src/stream/__tests__/GenerationJobManager.resumeReplay.spec.ts +++ b/packages/api/src/stream/__tests__/GenerationJobManager.resumeReplay.spec.ts @@ -67,6 +67,22 @@ describe('GenerationJobManager resume replay events', () => { manager = undefined; }); + test('projects regeneration ownership into resume state', async () => { + manager = createInMemoryManager(); + const streamId = `regenerate-resume-${Date.now()}`; + await manager.createJob(streamId, 'user-1', streamId, { + initialMetadata: { + responseMessageId: 'edited-response', + isRegenerate: true, + }, + }); + + await expect(manager.getResumeState(streamId)).resolves.toMatchObject({ + responseMessageId: 'edited-response', + isRegenerate: true, + }); + }); + test('includes OAuth run step and delta replay events in resume state', async () => { manager = createInMemoryManager(); const streamId = `oauth-delta-resume-${Date.now()}`; diff --git a/packages/api/src/stream/__tests__/RedisJobStore.spec.ts b/packages/api/src/stream/__tests__/RedisJobStore.spec.ts index ff3be25270e..5610d3e59cc 100644 --- a/packages/api/src/stream/__tests__/RedisJobStore.spec.ts +++ b/packages/api/src/stream/__tests__/RedisJobStore.spec.ts @@ -327,6 +327,11 @@ describe('RedisJobStore', () => { parentMessageId: 'parent-1', }, responseMessageId: 'response-1', + mcpRequestBody: { + messageId: 'response-1', + conversationId: 'overridden-conversation', + parentMessageId: 'response-1', + }, sender: 'Agent', endpoint: 'agents', iconURL: 'https://example.com/icon.png', @@ -389,6 +394,11 @@ describe('RedisJobStore', () => { parentMessageId: 'parent-1', }, responseMessageId: 'response-1', + mcpRequestBody: { + messageId: 'response-1', + conversationId: 'overridden-conversation', + parentMessageId: 'response-1', + }, sender: 'Agent', endpoint: 'agents', iconURL: 'https://example.com/icon.png', @@ -423,6 +433,11 @@ describe('RedisJobStore', () => { expect(storedFields).toMatchObject({ conversationId: 'conversation-1', responseMessageId: 'response-1', + mcpRequestBody: JSON.stringify({ + messageId: 'response-1', + conversationId: 'overridden-conversation', + parentMessageId: 'response-1', + }), agent_id: 'agent-1', isTemporary: '0', scheduleId: 'schedule-1', diff --git a/packages/api/src/stream/__tests__/startup.spec.ts b/packages/api/src/stream/__tests__/startup.spec.ts index 7298dd40265..227635cd496 100644 --- a/packages/api/src/stream/__tests__/startup.spec.ts +++ b/packages/api/src/stream/__tests__/startup.spec.ts @@ -99,6 +99,12 @@ describe('GenerationJobManager startup telemetry', () => { parentMessageId: 'parent-1', }, responseMessageId: 'response-1', + isRegenerate: true, + mcpRequestBody: { + messageId: 'response-1', + conversationId: 'overridden-conversation', + parentMessageId: 'response-1', + }, sender: 'Agent', endpoint: 'agents', iconURL: 'https://example.com/icon.png', @@ -129,6 +135,12 @@ describe('GenerationJobManager startup telemetry', () => { parentMessageId: 'parent-1', }, responseMessageId: 'response-1', + isRegenerate: true, + mcpRequestBody: { + messageId: 'response-1', + conversationId: 'overridden-conversation', + parentMessageId: 'response-1', + }, sender: 'Agent', endpoint: 'agents', iconURL: 'https://example.com/icon.png', diff --git a/packages/api/src/stream/implementations/RedisJobStore.ts b/packages/api/src/stream/implementations/RedisJobStore.ts index ea46b4a4e1b..7aa87ebfa52 100644 --- a/packages/api/src/stream/implementations/RedisJobStore.ts +++ b/packages/api/src/stream/implementations/RedisJobStore.ts @@ -4547,6 +4547,8 @@ export class RedisJobStore implements IJobStoreV2 { recoveredSteerId: data.recoveredSteerId || undefined, userMessage: data.userMessage ? JSON.parse(data.userMessage) : undefined, responseMessageId: data.responseMessageId || undefined, + isRegenerate: data.isRegenerate != null ? data.isRegenerate === '1' : undefined, + mcpRequestBody: data.mcpRequestBody ? JSON.parse(data.mcpRequestBody) : undefined, createdEventEmitted: data.createdEventEmitted === '1', sender: data.sender || undefined, syncSent: data.syncSent === '1', diff --git a/packages/api/src/stream/interfaces/IJobStore.ts b/packages/api/src/stream/interfaces/IJobStore.ts index a03e28298d1..a8438b6b606 100644 --- a/packages/api/src/stream/interfaces/IJobStore.ts +++ b/packages/api/src/stream/interfaces/IJobStore.ts @@ -3,6 +3,7 @@ import type { StandardGraph } from '@librechat/agents'; import type { ActivityPhaseSnapshot } from '~/agents/activityPhases/runtime'; import type { ResolvedAskUserQuestion } from '~/agents/hitl/resume'; import type { RecoveredSteerPayload } from '../SteerRecovery'; +import type { MCPRuntimeRequestBody } from '~/mcp/types'; /** * A pause owner has this long to durably persist the interrupted turn before @@ -75,6 +76,11 @@ export interface SerializableJobData { /** Response message ID for reconnection */ responseMessageId?: string; + /** Whether this generation replaces an existing assistant branch. */ + isRegenerate?: boolean; + /** Exact normalized MCP placeholder identity for this turn. */ + mcpRequestBody?: MCPRuntimeRequestBody; + /** * Whether this run has activity labels enabled (per-endpoint * `activityLabel: true`). Set once at run start so the resume path can @@ -294,6 +300,8 @@ export type JobMetadataPatch = Partial< Pick< SerializableJobData, | 'responseMessageId' + | 'isRegenerate' + | 'mcpRequestBody' | 'sender' | 'conversationId' | 'userMessage' diff --git a/packages/api/src/stream/metadata.ts b/packages/api/src/stream/metadata.ts index b334412267f..d7aade47832 100644 --- a/packages/api/src/stream/metadata.ts +++ b/packages/api/src/stream/metadata.ts @@ -6,6 +6,12 @@ export function sanitizeJobMetadata(metadata: Partial): J if (metadata.responseMessageId) { patch.responseMessageId = metadata.responseMessageId; } + if (metadata.isRegenerate !== undefined) { + patch.isRegenerate = metadata.isRegenerate; + } + if (metadata.mcpRequestBody) { + patch.mcpRequestBody = metadata.mcpRequestBody; + } if (metadata.sender) { patch.sender = metadata.sender; } diff --git a/packages/api/src/tools/classification.spec.ts b/packages/api/src/tools/classification.spec.ts index b543d2bad19..642bbc1e4ac 100644 --- a/packages/api/src/tools/classification.spec.ts +++ b/packages/api/src/tools/classification.spec.ts @@ -4,8 +4,10 @@ import type { GenericTool } from '@librechat/agents'; import type { LCToolRegistry } from './classification'; import { buildToolRegistryFromAgentOptions, + aliasMCPToolOptions, agentHasProgrammaticTools, buildToolClassification, + collectMCPToolAliases, getServerNameFromTool, agentHasDeferredTools, } from './classification'; @@ -28,6 +30,111 @@ describe('classification.ts', () => { }); }); + describe('collectMCPToolAliases', () => { + it('collects both alias directions from definitions', () => { + const defs = [ + { name: 'search_mcp_acme', serverName: 'acme', serverToolName: 'acme_search' }, + { + name: 'acme_list_mcp_acme', + serverName: 'acme', + serverToolName: 'acme_list', + currentToolName: 'list', + }, + { name: 'plain_mcp_acme', serverName: 'acme' }, + ]; + + expect(collectMCPToolAliases(defs)).toEqual([ + { name: 'search_mcp_acme', aliasName: 'acme_search_mcp_acme' }, + { name: 'acme_list_mcp_acme', aliasName: 'list_mcp_acme' }, + ]); + }); + + it('normalizes the server name when reconstructing alias keys', () => { + const defs = [ + { + name: 'search_mcp_My_Server', + serverName: 'My Server', + serverToolName: 'my_server_search', + }, + ]; + + expect(collectMCPToolAliases(defs)).toEqual([ + { name: 'search_mcp_My_Server', aliasName: 'my_server_search_mcp_My_Server' }, + ]); + }); + }); + + describe('aliasMCPToolOptions', () => { + it('aliases pre-strip option keys onto the current instance name, identity-gated', () => { + /** Wildcard-expanded catalogs rename stripped tools without any + * `agent.tools` entry to preserve the spelling — persisted defer, + * programmatic, background, and intent settings must follow. */ + const defs = [ + { + name: 'search_mcp_acme', + serverName: 'acme', + serverToolName: 'acme_search', + }, + { name: 'list_items_mcp_acme', serverName: 'acme' }, + ]; + const agentToolOptions: AgentToolOptions = { + acme_search_mcp_acme: { defer_loading: true }, + }; + + aliasMCPToolOptions(collectMCPToolAliases(defs), agentToolOptions); + + expect(agentToolOptions['search_mcp_acme']).toEqual({ defer_loading: true }); + const registry = buildToolRegistryFromAgentOptions(defs, agentToolOptions); + expect(registry.get('search_mcp_acme')?.defer_loading).toBe(true); + }); + + it('aliases current-keyed options back onto a legacy-named instance', () => { + /** The editor migrates `tool_options` keys to the current catalog + * spelling, while an unedited `agent.tools` entry keeps the legacy + * instance name — options must follow the reverse direction too. */ + const defs = [ + { + name: 'acme_search_mcp_acme', + serverName: 'acme', + serverToolName: 'acme_search', + currentToolName: 'search', + }, + ]; + const agentToolOptions: AgentToolOptions = { + search_mcp_acme: { defer_loading: true }, + }; + + aliasMCPToolOptions(collectMCPToolAliases(defs), agentToolOptions); + + expect(agentToolOptions['acme_search_mcp_acme']).toEqual({ defer_loading: true }); + const registry = buildToolRegistryFromAgentOptions(defs, agentToolOptions); + expect(registry.get('acme_search_mcp_acme')?.defer_loading).toBe(true); + }); + + it('never overrides an explicit entry under the instance name', () => { + const defs = [{ name: 'search_mcp_acme', serverName: 'acme', serverToolName: 'acme_search' }]; + const agentToolOptions: AgentToolOptions = { + search_mcp_acme: { defer_loading: false }, + acme_search_mcp_acme: { defer_loading: true }, + }; + + aliasMCPToolOptions(collectMCPToolAliases(defs), agentToolOptions); + + expect(agentToolOptions['search_mcp_acme']).toEqual({ defer_loading: false }); + }); + + it('does nothing without recorded upstream identity', () => { + const defs = [{ name: 'search_mcp_acme', serverName: 'acme' }]; + const agentToolOptions: AgentToolOptions = { + acme_search_mcp_acme: { defer_loading: true }, + }; + + aliasMCPToolOptions(collectMCPToolAliases(defs), agentToolOptions); + + expect(agentToolOptions['search_mcp_acme']).toBeUndefined(); + }); + }); + describe('buildToolRegistryFromAgentOptions', () => { it('should use agent tool options for defer_loading', () => { const tools = [ diff --git a/packages/api/src/tools/classification.ts b/packages/api/src/tools/classification.ts index 2ede26ffcb4..be83fa08ef6 100644 --- a/packages/api/src/tools/classification.ts +++ b/packages/api/src/tools/classification.ts @@ -6,7 +6,7 @@ */ import { logger } from '@librechat/data-schemas'; -import { Constants } from 'librechat-data-provider'; +import { Constants, normalizeServerName } from 'librechat-data-provider'; import { Providers, createToolSearch, @@ -33,6 +33,47 @@ export interface ToolDefinition { parameters?: JsonSchemaType; /** MCP server name extracted from tool name */ serverName?: string; + /** Raw upstream tool name when the model-facing key stripped a redundant server-name prefix */ + serverToolName?: string; + /** Current catalog tool name when a LEGACY persisted key kept its pre-strip spelling */ + currentToolName?: string; +} + +/** An MCP tool name plus its OTHER spelling (legacy for stripped instances, current for legacy-named ones). */ +export interface MCPToolAlias { + name: string; + aliasName: string; +} + +/** + * Collects both directions of identity aliases from MCP tool definitions, so + * approval policies and hook matchers written against EITHER spelling keep + * applying: a stripped instance aliases its pre-strip name, and a + * legacy-named instance (persisted key retained) aliases its current catalog + * name. Works in both loading modes because both funnel their definitions + * through {@link buildToolClassification}. + */ +export function collectMCPToolAliases(mcpToolDefs: ToolDefinition[]): MCPToolAlias[] { + const aliases: MCPToolAlias[] = []; + for (const def of mcpToolDefs) { + if (!def.serverName) { + continue; + } + const keySuffix = `${Constants.mcp_delimiter}${normalizeServerName(def.serverName)}`; + if (def.serverToolName) { + const aliasName = `${def.serverToolName}${keySuffix}`; + if (aliasName !== def.name) { + aliases.push({ name: def.name, aliasName }); + } + } + if (def.currentToolName) { + const aliasName = `${def.currentToolName}${keySuffix}`; + if (aliasName !== def.name) { + aliases.push({ name: def.name, aliasName }); + } + } + } + return aliases; } /** @@ -49,6 +90,31 @@ export function getServerNameFromTool(toolName: string): string | undefined { return undefined; } +/** + * Aliases persisted `tool_options` keys onto the instance names IN PLACE, so + * every downstream reader of `agent.tool_options` (the registry build for + * defer/programmatic, the background and intent passes) sees the healed keys + * in BOTH loading modes and BOTH spelling directions: options keyed by a + * pre-strip spelling follow a renamed (wildcard-expanded) instance, and + * options the editor migrated to the CURRENT catalog spelling still reach a + * legacy-named instance an unedited `agent.tools` entry retained. + * Identity-gated through {@link collectMCPToolAliases}, and an explicit + * entry under the instance's own name always wins. + */ +export function aliasMCPToolOptions( + aliases: readonly MCPToolAlias[], + agentToolOptions?: AgentToolOptions, +): void { + if (!agentToolOptions || Object.keys(agentToolOptions).length === 0) { + return; + } + for (const { name, aliasName } of aliases) { + if (agentToolOptions[name] == null && agentToolOptions[aliasName] != null) { + agentToolOptions[name] = agentToolOptions[aliasName]; + } + } +} + /** * Builds a tool registry from agent-level tool_options. * @@ -104,6 +170,10 @@ interface MCPToolInstance { mcpJsonSchema?: JsonSchemaType; /** Server this tool came from, carried from resolution instead of re-parsed */ mcpRawServerName?: string; + /** Raw upstream tool name when the instance name stripped a redundant server-name prefix */ + mcpServerToolName?: string; + /** Current catalog tool name when a legacy persisted key kept its pre-strip spelling */ + mcpCurrentToolName?: string; } /** @@ -129,6 +199,14 @@ export function extractMCPToolDefinition(tool: MCPToolInstance): ToolDefinition def.serverName = serverName; } + if (tool.mcpServerToolName) { + def.serverToolName = tool.mcpServerToolName; + } + + if (tool.mcpCurrentToolName) { + def.currentToolName = tool.mcpCurrentToolName; + } + return def; } @@ -214,6 +292,8 @@ export interface BuildToolClassificationResult { additionalTools: GenericTool[]; /** Whether any tools have defer_loading enabled (precomputed for efficiency) */ hasDeferredTools: boolean; + /** Both-direction identity aliases for MCP tools whose key spelling changed (see {@link collectMCPToolAliases}) */ + mcpToolAliases: MCPToolAlias[]; } /** @@ -282,10 +362,13 @@ export async function buildToolClassification( toolDefinitions: [], toolRegistry: undefined, hasDeferredTools: false, + mcpToolAliases: [], }; } const mcpToolDefs = mcpTools.map(extractMCPToolDefinition); + const mcpToolAliases = collectMCPToolAliases(mcpToolDefs); + aliasMCPToolOptions(mcpToolAliases, agentToolOptions); const toolRegistry: LCToolRegistry = buildToolRegistry(mcpToolDefs, agentToolOptions); /** Clean up temporary mcpJsonSchema property from tools now that registry is populated */ @@ -318,7 +401,13 @@ export async function buildToolClassification( logger.debug( `[buildToolClassification] Agent ${agentId} has no programmatic or deferred tools, skipping PTC/ToolSearch`, ); - return { toolRegistry, toolDefinitions, additionalTools, hasDeferredTools: false }; + return { + toolRegistry, + toolDefinitions, + additionalTools, + hasDeferredTools: false, + mcpToolAliases, + }; } /** Tool search uses local mode (no API key needed) */ @@ -357,7 +446,7 @@ export async function buildToolClassification( } if (!hasProgrammaticTools) { - return { toolRegistry, toolDefinitions, additionalTools, hasDeferredTools }; + return { toolRegistry, toolDefinitions, additionalTools, hasDeferredTools, mcpToolAliases }; } /** In definitions-only mode, add PTC definition without creating the tool instance */ @@ -374,7 +463,7 @@ export async function buildToolClassification( logger.debug( `[buildToolClassification] PTC definition added for agent ${agentId} (definitions only)`, ); - return { toolRegistry, toolDefinitions, additionalTools, hasDeferredTools }; + return { toolRegistry, toolDefinitions, additionalTools, hasDeferredTools, mcpToolAliases }; } try { @@ -408,5 +497,5 @@ export async function buildToolClassification( logger.error('[buildToolClassification] Error creating PTC tool:', error); } - return { toolRegistry, toolDefinitions, additionalTools, hasDeferredTools }; + return { toolRegistry, toolDefinitions, additionalTools, hasDeferredTools, mcpToolAliases }; } diff --git a/packages/api/src/tools/definitions.spec.ts b/packages/api/src/tools/definitions.spec.ts index 5df9b16d988..9e7fc239e19 100644 --- a/packages/api/src/tools/definitions.spec.ts +++ b/packages/api/src/tools/definitions.spec.ts @@ -575,6 +575,72 @@ describe('definitions.ts', () => { expect(getItemDef?.description).toBe('Get a specific item'); }); + it('resolves a pre-strip persisted key against the stripped catalog, keeping the persisted name', async () => { + /** Catalog keys drop a redundant leading server-name prefix; an agent + * saved before that must still resolve, and the definition keeps the + * persisted spelling so it matches the runtime instance name. */ + const mockServerTools = { + search_mcp_acme: { + serverToolName: 'acme_search', + function: { + name: 'search_mcp_acme', + description: 'Search things', + parameters: { type: 'object', properties: {} }, + }, + }, + }; + + mockGetOrFetchMCPServerTools.mockResolvedValue(mockServerTools); + + const params: LoadToolDefinitionsParams = { + userId: 'user-123', + agentId: 'agent-123', + tools: ['acme_search_mcp_acme'], + }; + + const deps: LoadToolDefinitionsDeps = { + getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools, + isBuiltInTool: mockIsBuiltInTool, + }; + + const result = await loadToolDefinitions(params, deps); + + expect(result.toolDefinitions).toHaveLength(1); + expect(result.toolDefinitions[0]?.name).toBe('acme_search_mcp_acme'); + expect(result.toolDefinitions[0]?.description).toBe('Search things'); + }); + + it('rejects a stripped-spelling match without matching upstream identity', async () => { + /** A stale key for a removed tool must not resolve onto a DIFFERENT + * sibling whose key merely coincides with the stripped spelling. */ + const mockServerTools = { + acme_foo_mcp_acme: { + function: { + name: 'acme_foo_mcp_acme', + description: 'Different tool', + parameters: { type: 'object', properties: {} }, + }, + }, + }; + + mockGetOrFetchMCPServerTools.mockResolvedValue(mockServerTools); + + const params: LoadToolDefinitionsParams = { + userId: 'user-123', + agentId: 'agent-123', + tools: ['acme_acme_foo_mcp_acme'], + }; + + const deps: LoadToolDefinitionsDeps = { + getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools, + isBuiltInTool: mockIsBuiltInTool, + }; + + const result = await loadToolDefinitions(params, deps); + + expect(result.toolDefinitions).toHaveLength(0); + }); + it('union-flattens MCP tool schemas for Google, but preserves unions otherwise', async () => { const mockServerTools = { issue_write_mcp_github: { diff --git a/packages/api/src/tools/definitions.ts b/packages/api/src/tools/definitions.ts index 8904bf9fcbb..d4b5967b1d6 100644 --- a/packages/api/src/tools/definitions.ts +++ b/packages/api/src/tools/definitions.ts @@ -10,11 +10,13 @@ import { Constants, isActionTool, splitMCPToolKey, + normalizeServerName, + stripServerNamePrefix, buildServerNameAliases, } from 'librechat-data-provider'; import type { LCToolRegistry, JsonSchemaType, LCTool, GenericTool } from '@librechat/agents'; import type { AgentToolOptions } from 'librechat-data-provider'; -import type { ToolDefinition } from './classification'; +import type { MCPToolAlias, ToolDefinition } from './classification'; import { resolveJsonSchemaRefs, normalizeJsonSchema, sanitizeGeminiSchema } from '~/mcp/zod'; import { buildToolClassification } from './classification'; import { getToolDefinition } from './registry/definitions'; @@ -27,6 +29,7 @@ export interface MCPServerTool { description?: string; parameters?: JsonSchemaType; }; + serverToolName?: string; } export type MCPServerTools = Record; @@ -92,6 +95,8 @@ export interface LoadToolDefinitionsResult { toolDefinitions: (ToolDefinition | LCTool)[]; toolRegistry: LCToolRegistry; hasDeferredTools: boolean; + /** Both-direction identity aliases for MCP tools whose key spelling changed */ + mcpToolAliases: MCPToolAlias[]; mcpResolution: { expectedToolCount: number; resolvedToolCount: number; @@ -145,6 +150,7 @@ export async function loadToolDefinitions( toolDefinitions: [], toolRegistry: new Map(), hasDeferredTools: false, + mcpToolAliases: [], mcpResolution: { expectedToolCount: 0, resolvedToolCount: 0 }, }; @@ -247,9 +253,38 @@ export async function loadToolDefinitions( continue; } + /** Catalog keys are built after redundant server-name-prefix stripping — + * a pre-strip persisted key (`acme_search_mcp_acme`) must also try its + * stripped spelling or the agent fails initialization with its expected + * tools "unavailable". The definition keeps the PERSISTED name so it + * matches the runtime instance `createMCPTool` builds for the same key, + * and the stripped entry is accepted only when its recorded raw name + * PROVES the same upstream identity. */ + const findToolMatch = ( + tools: Record, + ): { def: MCPServerTool; currentToolName?: string } | undefined => { + const direct = tools[toolName]; + if (direct?.function) { + return { def: direct }; + } + const keyServerName = normalizeServerName(serverName); + const [toolPart] = splitMCPToolKey(toolName, [parsed]); + const strippedPart = stripServerNamePrefix(toolPart, keyServerName); + if (strippedPart === toolPart) { + return undefined; + } + const entry = tools[`${strippedPart}${Constants.mcp_delimiter}${keyServerName}`]; + /** `currentToolName` records the catalog spelling so approval policies + * and hook matchers written against it still reach this legacy-named + * definition (see `collectMCPToolAliases`). */ + return entry?.serverToolName === toolPart + ? { def: entry, currentToolName: strippedPart } + : undefined; + }; + const selectedToolMissing = isMCPAllPlaceholder(toolName) ? Object.keys(serverTools).length === 0 - : !serverTools[toolName]?.function; + : !findToolMatch(serverTools)?.def.function; if (selectedToolMissing && refreshMCPServerTools && !refreshedServerNames.has(serverName)) { refreshedServerNames.add(serverName); const refreshedTools = await refreshMCPServerTools(userId, serverName); @@ -267,6 +302,7 @@ export async function loadToolDefinitions( description: toolDef.function.description || undefined, parameters: buildMcpParameters(toolDef.function.parameters), serverName, + serverToolName: toolDef.serverToolName, }); resolvedMCPToolCount++; } @@ -274,13 +310,15 @@ export async function loadToolDefinitions( continue; } - const toolDef = serverTools[toolName]; - if (toolDef?.function) { + const toolMatch = findToolMatch(serverTools); + if (toolMatch?.def.function) { mcpToolDefs.push({ name: toolName, - description: toolDef.function.description || undefined, - parameters: buildMcpParameters(toolDef.function.parameters), + description: toolMatch.def.function.description || undefined, + parameters: buildMcpParameters(toolMatch.def.function.parameters), serverName, + serverToolName: toolMatch.def.serverToolName, + currentToolName: toolMatch.currentToolName, }); resolvedMCPToolCount++; } @@ -301,6 +339,8 @@ export async function loadToolDefinitions( mcp: true as const, mcpJsonSchema: def.parameters, mcpRawServerName: def.serverName, + mcpServerToolName: def.serverToolName, + mcpCurrentToolName: def.currentToolName, })) as unknown as GenericTool[]; const classificationResult = await buildToolClassification({ @@ -350,6 +390,7 @@ export async function loadToolDefinitions( toolDefinitions: allDefinitions, toolRegistry, hasDeferredTools, + mcpToolAliases: classificationResult.mcpToolAliases, mcpResolution: { expectedToolCount: expectedMCPToolCount, resolvedToolCount: resolvedMCPToolCount, diff --git a/packages/api/src/types/stream.ts b/packages/api/src/types/stream.ts index b6727ebfbfa..16e8e8124c2 100644 --- a/packages/api/src/types/stream.ts +++ b/packages/api/src/types/stream.ts @@ -2,6 +2,7 @@ import type { Agents } from 'librechat-data-provider'; import type { EventEmitter } from 'events'; import type { ActivityPhaseSnapshot } from '~/agents/activityPhases/runtime'; import type { ResolvedAskUserQuestion } from '../agents/hitl/resume'; +import type { MCPRuntimeRequestBody } from '../mcp/types'; import type { ServerSentEvent } from './events'; export interface GenerationJobMetadata { @@ -17,6 +18,11 @@ export interface GenerationJobMetadata { userMessage?: Agents.UserMessageMeta; /** Response message ID for tracking */ responseMessageId?: string; + /** Whether this generation replaces an existing assistant branch. */ + isRegenerate?: boolean; + /** Exact normalized MCP placeholder identity for this turn. Persisted so HITL + * resume does not reconstruct a different parent or overridden conversation. */ + mcpRequestBody?: MCPRuntimeRequestBody; /** Sender label for the response (e.g., "GPT-4.1", "Claude") */ sender?: string; /** Endpoint identifier for abort handling */ diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 7eb3c25a31e..c6a485d2d55 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -116,8 +116,10 @@ export const conversations = (params: q.ConversationListParams) => { export const conversationById = (id: string) => `${conversationsRoot}/${id}`; -export const subagentThread = (parentConversationId: string, threadId: string) => - `${conversationsRoot}/${encodeURIComponent(parentConversationId)}/subagents/${encodeURIComponent(threadId)}`; +export const subagentThread = (parentConversationId: string, threadId: string, taskId?: string) => { + const endpoint = `${conversationsRoot}/${encodeURIComponent(parentConversationId)}/subagents/${encodeURIComponent(threadId)}`; + return taskId == null ? endpoint : `${endpoint}?taskId=${encodeURIComponent(taskId)}`; +}; export const genTitle = (conversationId: string) => `${conversationsRoot}/gen_title/${encodeURIComponent(conversationId)}`; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index c7661f39768..7ef02184bc2 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -12,6 +12,7 @@ import { ComponentTypes, SettingTypes, OptionTypes } from './generate'; import { MAX_SUBAGENTS, MAX_SUBAGENTS_CEILING } from './limits'; import { STATEFUL_CODE_ENVIRONMENTS } from './stateful-code'; import { specsConfigSchema, TSpecsConfig } from './models'; +import { isActionTool } from './types/assistants'; import { REFILL_INTERVAL_UNITS } from './balance'; import { fileConfigSchema } from './file-config'; import { apiBaseUrl } from './api-endpoints'; @@ -3177,6 +3178,120 @@ export function normalizeMCPToolKey(toolKey: string, rawServerNames: readonly st return `${toolKey.slice(0, toolKey.length - matched.length)}${normalized}`; } +/** + * Strips a redundant leading server-name prefix from a raw upstream tool name + * before it is embedded into a model-facing key, so the key doesn't carry the + * server twice (`acme_trace_..._mcp_acme`) and push long tool names + * past provider function-name limits (64 chars). The match is case-insensitive + * because display-cased server names ("Acme") conventionally prefix their + * tools in lowercase. Ingestion that strips must record the original name + * (`serverToolName` on the cached definition) — tool calls send THAT name back + * to the server, never the stripped one. Catalog producers must not call this + * directly: only {@link stripServerNamePrefixes} sees the whole sibling set and + * can keep colliding results apart. + */ +export function stripServerNamePrefix(toolName: string, normalizedServerName: string): string { + const prefixLength = normalizedServerName.length + 1; + if (toolName.length <= prefixLength) { + return toolName; + } + const prefix = toolName.slice(0, prefixLength).toLowerCase(); + if (prefix !== `${normalizedServerName.toLowerCase()}_`) { + return toolName; + } + const stripped = toolName.slice(prefixLength); + if (isReservedMCPToolName(stripped)) { + return toolName; + } + /** `isActionTool` classifies keys by the RELATIVE position of `_action_` + * and `_mcp_`; stripping moves the first `_mcp_` earlier, so a server + * whose normalized name contains `_action_` could see a real MCP tool + * reclassified as an OpenAPI action (bypassing MCP authorization). Never + * produce a key whose classification differs from the raw key's. */ + const keySuffix = `${Constants.mcp_delimiter}${normalizedServerName}`; + if (isActionTool(`${stripped}${keySuffix}`) !== isActionTool(`${toolName}${keySuffix}`)) { + return toolName; + } + return stripped; +} + +/** + * Synthetic markers consumed by prefix (`isMCPAllPlaceholder`, the server-pin + * skip, the client's OAuth stream classification), so each reserves BOTH its + * exact name and its `${marker}${mcp_delimiter}` namespace: a stripped + * remainder inside any of them would turn a real upstream tool into the + * server-wide wildcard, the UI pin placeholder, or a synthetic OAuth call. + */ +const RESERVED_MCP_TOOL_MARKERS: readonly string[] = [ + `${Constants.mcp_all}`, + `${Constants.mcp_server}`, + 'oauth', +]; + +function isReservedMCPToolName(toolName: string): boolean { + /** `mcp_` opens the server-scoped pluginKey namespace (`mcp_${serverName}`), + * and `lc_transfer_to_` opens the agent-handoff namespace (the client + * renders such calls as handoffs; the background and intent passes exclude + * them) — pre-strip tool keys could never enter either, since they always + * began with the server name itself. */ + if ( + toolName.startsWith(`${Constants.mcp_prefix}`) || + toolName.startsWith(`${Constants.LC_TRANSFER_TO_}`) + ) { + return true; + } + return RESERVED_MCP_TOOL_MARKERS.some( + (marker) => toolName === marker || toolName.startsWith(`${marker}${Constants.mcp_delimiter}`), + ); +} + +/** + * Maps every raw tool name in a server's catalog to its model-facing name, + * stripping redundant server-name prefixes collision-free: when two names + * yield the same result — a bare `foo` next to `_foo`, or the + * case-variant pair `_Foo` / `_Foo` under the case-insensitive + * prefix match — every collider keeps its raw name, so two distinct upstream + * tools can never collapse onto one key. Unprefixed names count against the + * result set through their identity mapping, which is what makes the bare-name + * case fall out of the same counter. + */ +export function stripServerNamePrefixes( + toolNames: readonly string[], + normalizedServerName: string, +): Map { + const rawNames = new Set(toolNames); + const finalNames = new Map( + toolNames.map((name) => { + const stripped = stripServerNamePrefix(name, normalizedServerName); + /** Every sibling's RAW name is reserved even when that sibling itself + * strips away: keys persisted BEFORE stripping embed raw names, so a + * stripped result landing on another sibling's raw name would route + * that sibling's legacy references to the wrong upstream tool. */ + return [name, stripped !== name && rawNames.has(stripped) ? name : stripped]; + }), + ); + /** Reverting a collider to its raw name can itself collide with ANOTHER + * sibling's stripped result (`foo` / `acme_foo` / `acme_acme_foo`), so the + * guard iterates to a fixpoint. Each pass converts at least one stripped + * result back to its unique raw name, so it terminates within the catalog + * size. */ + let changed = true; + while (changed) { + changed = false; + const counts = new Map(); + finalNames.forEach((result) => { + counts.set(result, (counts.get(result) ?? 0) + 1); + }); + finalNames.forEach((result, raw) => { + if (result !== raw && (counts.get(result) ?? 0) > 1) { + finalNames.set(raw, raw); + changed = true; + } + }); + } + return finalNames; +} + export function splitMCPToolKey( toolKey: string, knownServerNames?: readonly string[], diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 2ab212cf8c4..de3e97e2498 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -1005,8 +1005,9 @@ export function getMessagesByConvoId(conversationId: string): Promise { - return request.get(endpoints.subagentThread(parentConversationId, threadId)); + return request.get(endpoints.subagentThread(parentConversationId, threadId, taskId)); } export function getPrompt(id: string): Promise<{ prompt: t.TPrompt }> { diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index ba40501687a..68f435a948e 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -765,6 +765,9 @@ export const tPluginSchema = z.object({ chatMenu: z.boolean().optional(), isButton: z.boolean().optional(), toolkit: z.boolean().optional(), + /** Raw upstream tool name when the model-facing key stripped a redundant + * server-name prefix — proves upstream identity for legacy id migration. */ + serverToolName: z.string().optional(), }); export type TPlugin = z.infer; diff --git a/packages/data-provider/src/splitMCPToolKey.spec.ts b/packages/data-provider/src/splitMCPToolKey.spec.ts index 1b05ac404d2..e39d55afc92 100644 --- a/packages/data-provider/src/splitMCPToolKey.spec.ts +++ b/packages/data-provider/src/splitMCPToolKey.spec.ts @@ -4,6 +4,8 @@ import { splitToolCallName, normalizeMCPToolKey, buildServerNameAliases, + stripServerNamePrefix, + stripServerNamePrefixes, } from './config'; describe('splitMCPToolKey', () => { @@ -208,3 +210,127 @@ describe('splitToolCallName oauth precedence', () => { ]); }); }); + +describe('stripServerNamePrefix', () => { + it('strips a leading server-name prefix from the tool name', () => { + expect(stripServerNamePrefix('acme_trace_top_time_consuming_operations', 'acme')).toBe( + 'trace_top_time_consuming_operations', + ); + }); + + it('matches the prefix case-insensitively', () => { + /** Display-cased server names ("Acme") conventionally prefix their + * tools in lowercase — the redundancy is the same either way. */ + expect(stripServerNamePrefix('acme_list_services', 'Acme')).toBe('list_services'); + }); + + it('returns the name unchanged when the prefix does not match', () => { + expect(stripServerNamePrefix('github_create_issue', 'acme')).toBe('github_create_issue'); + }); + + it('requires the underscore separator, not a bare substring match', () => { + expect(stripServerNamePrefix('acmecorp_tool', 'acme')).toBe('acmecorp_tool'); + }); + + it('keeps a name that is exactly the server name or would strip to empty', () => { + expect(stripServerNamePrefix('acme', 'acme')).toBe('acme'); + expect(stripServerNamePrefix('acme_', 'acme')).toBe('acme_'); + }); +}); + +describe('stripServerNamePrefixes', () => { + it('maps every raw name to its stripped model-facing name', () => { + const map = stripServerNamePrefixes(['acme_search', 'list_services'], 'acme'); + expect(map.get('acme_search')).toBe('search'); + expect(map.get('list_services')).toBe('list_services'); + }); + + it('keeps the prefixed name when stripping would collide with a bare sibling', () => { + /** A server exposing BOTH `search` and `acme_search` must keep two + * distinct keys — stripping would collapse them into one. */ + const map = stripServerNamePrefixes(['search', 'acme_search'], 'acme'); + expect(map.get('search')).toBe('search'); + expect(map.get('acme_search')).toBe('acme_search'); + }); + + it('keeps both raw names when case-variant prefixed siblings strip to the same result', () => { + /** The prefix match is case-insensitive, so `acme_Foo` and `Acme_Foo` are + * distinct upstream tools with the SAME stripped remainder — both must + * fall back to their raw names or one silently overwrites the other. */ + const map = stripServerNamePrefixes(['acme_Foo', 'Acme_Foo'], 'acme'); + expect(map.get('acme_Foo')).toBe('acme_Foo'); + expect(map.get('Acme_Foo')).toBe('Acme_Foo'); + }); + + it('collisions do not suppress stripping of unrelated siblings', () => { + const map = stripServerNamePrefixes(['search', 'acme_search', 'acme_trace'], 'acme'); + expect(map.get('acme_trace')).toBe('trace'); + }); + + it('reserves every sibling raw name, even when that sibling itself strips', () => { + /** Keys persisted BEFORE stripping embed raw names: if `acme_acme_foo` + * stripped to `acme_foo`, a pre-rollout reference to the REAL `acme_foo` + * would exact-match the wrong tool in the same snapshot. */ + const map = stripServerNamePrefixes(['acme_foo', 'acme_acme_foo'], 'acme'); + expect(map.get('acme_foo')).toBe('foo'); + expect(map.get('acme_acme_foo')).toBe('acme_acme_foo'); + }); + + it('resolves secondary collisions introduced by a fallback to a raw name', () => { + /** `acme_foo` falls back to raw because of the bare `foo`, which then + * collides with `acme_acme_foo`'s stripped result — the guard must + * iterate until no two final names coincide. */ + const map = stripServerNamePrefixes(['foo', 'acme_foo', 'acme_acme_foo'], 'acme'); + expect(map.get('foo')).toBe('foo'); + expect(map.get('acme_foo')).toBe('acme_foo'); + expect(map.get('acme_acme_foo')).toBe('acme_acme_foo'); + expect(new Set(map.values()).size).toBe(3); + }); + + it('never strips a remainder that equals a synthetic MCP marker', () => { + /** `sys__all__sys` keys expand to every server tool, `sys__server__sys` + * keys are skipped as UI placeholders, and `oauth${mcp_delimiter}` names + * get OAuth-only handling in the client stream handlers — a real + * upstream tool must not be renamed onto any of them. */ + expect(stripServerNamePrefix(`acme_${Constants.mcp_all}`, 'acme')).toBe( + `acme_${Constants.mcp_all}`, + ); + expect(stripServerNamePrefix(`acme_${Constants.mcp_server}`, 'acme')).toBe( + `acme_${Constants.mcp_server}`, + ); + expect(stripServerNamePrefix('acme_oauth', 'acme')).toBe('acme_oauth'); + /** Each marker is consumed by PREFIX (`isMCPAllPlaceholder`, the + * server-pin skip, the client's OAuth classification), so the whole + * `${marker}${mcp_delimiter}` namespace stays raw, not just the exact + * name. */ + expect(stripServerNamePrefix(`acme_oauth${Constants.mcp_delimiter}reset`, 'acme')).toBe( + `acme_oauth${Constants.mcp_delimiter}reset`, + ); + expect( + stripServerNamePrefix(`acme_${Constants.mcp_all}${Constants.mcp_delimiter}reset`, 'acme'), + ).toBe(`acme_${Constants.mcp_all}${Constants.mcp_delimiter}reset`); + expect( + stripServerNamePrefix(`acme_${Constants.mcp_server}${Constants.mcp_delimiter}reset`, 'acme'), + ).toBe(`acme_${Constants.mcp_server}${Constants.mcp_delimiter}reset`); + /** `mcp_` opens the server-scoped pluginKey namespace and + * `lc_transfer_to_` the agent-handoff namespace — pre-strip tool keys + * could never enter either. */ + expect(stripServerNamePrefix('acme_mcp_status', 'acme')).toBe('acme_mcp_status'); + expect(stripServerNamePrefix('acme_lc_transfer_to_status', 'acme')).toBe( + 'acme_lc_transfer_to_status', + ); + }); + + it('never flips isActionTool classification for the produced key', () => { + /** `isActionTool` compares the FIRST `_action_` and `_mcp_` positions; + * stripping moves `_mcp_` earlier, so a server whose normalized name + * contains `_action_` (e.g. "svc action v1") would see a real MCP tool + * reclassified as an OpenAPI action and bypass MCP authorization. */ + expect(stripServerNamePrefix('svc_action_v1_report', 'svc_action_v1')).toBe( + 'svc_action_v1_report', + ); + /** A remainder containing `_action_` in the tool half does not flip and + * still strips. */ + expect(stripServerNamePrefix('acme_do_action_thing', 'acme')).toBe('do_action_thing'); + }); +}); diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts index ae8499c61f7..35d2e39c9f8 100644 --- a/packages/data-provider/src/types/agents.ts +++ b/packages/data-provider/src/types/agents.ts @@ -263,6 +263,8 @@ export namespace Agents { aggregatedContent?: MessageContentComplex[]; userMessage?: UserMessageMeta; responseMessageId?: string; + /** True when the live generation replaces an existing assistant branch. */ + isRegenerate?: boolean; conversationId?: string; sender?: string; iconURL?: string; diff --git a/packages/data-provider/src/types/queries.ts b/packages/data-provider/src/types/queries.ts index 8d95a94a914..1078135b422 100644 --- a/packages/data-provider/src/types/queries.ts +++ b/packages/data-provider/src/types/queries.ts @@ -130,6 +130,9 @@ export type MCPTool = { name: string; pluginKey: string; description: string; + /** Raw upstream tool name when the model-facing key stripped a redundant + * server-name prefix — gates the agent editor's legacy id migration. */ + serverToolName?: string; }; export type MCPServer = { @@ -209,6 +212,10 @@ export type ListRolesResponse = { export interface MCPServerStatus { requiresOAuth: boolean; + /** The server connects only inside a chat request because its config reads BODY placeholders. */ + requestScoped?: boolean; + /** Whether all declared per-user variables are present for an on-demand connection. */ + configurationState?: 'configured' | 'needs_configuration'; connectionState: 'disconnected' | 'connecting' | 'connected' | 'error'; authorizationState?: | 'not_required' @@ -229,6 +236,8 @@ export interface MCPServerConnectionStatusResponse { success: boolean; serverName: string; requiresOAuth: boolean; + requestScoped?: boolean; + configurationState?: MCPServerStatus['configurationState']; connectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error'; authorizationState?: MCPServerStatus['authorizationState']; } diff --git a/packages/data-provider/src/types/subagents.ts b/packages/data-provider/src/types/subagents.ts index fd35b67a988..cd05af19f1f 100644 --- a/packages/data-provider/src/types/subagents.ts +++ b/packages/data-provider/src/types/subagents.ts @@ -6,6 +6,31 @@ export type SubagentThreadStatus = | 'interrupted' | 'cancelled'; +/** + * A bounded, presentation-safe description of child work. This deliberately + * models user-visible activity instead of the LangChain messages, SSE events, + * or durable records that produced it. + */ +export type SubagentActivityItem = + | { + type: 'writing'; + text: string; + textTruncated?: boolean; + } + | { + type: 'reasoning'; + } + | { + type: 'tool'; + toolCallId: string; + name: string; + input?: string; + output?: string; + status: 'running' | 'completed' | 'failed' | 'cancelled'; + inputTruncated?: boolean; + outputTruncated?: boolean; + }; + export type SubagentThreadMessage = { messageId: string; parentMessageId: string | null; @@ -26,6 +51,9 @@ export type SubagentThreadView = { agentId?: string; title: string; status: SubagentThreadStatus; + /** Activity for the exact task requested by the parent card, when retained. */ + activity: SubagentActivityItem[]; + activityTruncated: boolean; messages: SubagentThreadMessage[]; historyTruncated: boolean; updatedAt?: string; diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index 9220c5f67a3..b1a56dc206a 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -8,6 +8,7 @@ export { createModels } from './models'; export { createMethods, CLIENT_MESSAGE_SELECT, + SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT, RoleConflictError, DEFAULT_REFRESH_TOKEN_EXPIRY, DEFAULT_SESSION_EXPIRY, diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index d90f48ed170..de61ec3e64f 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -47,6 +47,7 @@ import { createConversationTagMethods, type ConversationTagMethods } from './con import { createMessageMethods, CLIENT_MESSAGE_SELECT, + SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT, type MessageMethods, type SubagentThreadViewMessageRecord, type SubagentTaskResultClaim, @@ -146,7 +147,7 @@ export { }; export { tokenValues, cacheTokenValues, premiumTokenValues, defaultRate, createTxMethods }; export { permissionBitSupersets }; -export { CLIENT_MESSAGE_SELECT }; +export { CLIENT_MESSAGE_SELECT, SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT }; export { partitionIssues, validateSkillName, diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index 57a495bb553..30d3fe31056 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -3,7 +3,11 @@ import { v4 as uuidv4 } from 'uuid'; import { RetentionMode } from 'librechat-data-provider'; import { MongoMemoryServer } from 'mongodb-memory-server'; import type { IMessage } from '..'; -import { createMessageMethods, CLIENT_MESSAGE_SELECT } from './message'; +import { + createMessageMethods, + CLIENT_MESSAGE_SELECT, + SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT, +} from './message'; import { tenantStorage, runAsSystem } from '~/config/tenantContext'; import { createModels } from '../models'; import logger from '~/config/winston'; @@ -712,6 +716,77 @@ describe('Message Operations', () => { expect(messages[0]).not.toHaveProperty('user'); expect(messages[0]).not.toHaveProperty('conversationId'); }); + + it('projects the private transcript only for the explicitly selected task', async () => { + const conversationId = uuidv4(); + await saveMessage(mockCtx, { + messageId: 'task-a:assistant', + conversationId, + text: 'A', + user: 'user123', + subagentTranscript: { + taskId: 'task-a', + mode: 'append', + messagesJson: '[{"type":"ai","data":{"content":"A"}}]', + }, + }); + await saveMessage(mockCtx, { + messageId: 'task-b:assistant', + conversationId, + text: 'B', + user: 'user123', + subagentTranscript: { + taskId: 'task-b', + mode: 'append', + messagesJson: '[{"type":"ai","data":{"content":"B"}}]', + }, + }); + + const messages = await getMessagesForSubagentThreadView({ + user: 'user123', + conversationId, + limit: 10, + textCodePointLimit: 8_192, + taskId: 'task-a', + }); + + expect(messages).toHaveLength(1); + expect(messages[0]).toHaveProperty('messageId', 'task-a:assistant'); + expect(messages[0]).toHaveProperty('subagentTranscript.taskId', 'task-a'); + }); + + it('omits an oversized private transcript before returning the application result', async () => { + const conversationId = uuidv4(); + await saveMessage(mockCtx, { + messageId: 'task-large:assistant', + conversationId, + text: 'The bounded public answer remains available.', + user: 'user123', + subagentTranscript: { + taskId: 'task-large', + mode: 'append', + messagesJson: JSON.stringify([ + { + type: 'ai', + data: { content: 'x'.repeat(SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT + 1) }, + }, + ]), + }, + }); + + const messages = await getMessagesForSubagentThreadView({ + user: 'user123', + conversationId, + limit: 1, + textCodePointLimit: 8_192, + taskId: 'task-large', + }); + + expect(messages).toHaveLength(1); + expect(messages[0].text).toBe('The bounded public answer remains available.'); + expect(messages[0]).not.toHaveProperty('subagentTranscript'); + expect(messages[0].subagentTranscriptProjectionTruncated).toBe(true); + }); }); describe('deleteMessages', () => { diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index a1f89f6ed65..4d466decdcb 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -9,6 +9,14 @@ import logger from '~/config/winston'; /** Simple UUID v4 regex to replace zod validation */ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +/** + * Maximum private transcript JSON that may cross the MongoDB projection seam + * for the bounded public subagent-activity view. This gives the sanitizer + * enough source headroom while preventing multi-megabyte transcripts from + * being materialized merely to produce a 64 KiB public activity response. + */ +export const SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT: number = 256 * 1024; + /** * Exclusion projection for message reads that feed the chat client (the * conversation GET and shared-link reads). Every excluded field is either @@ -61,8 +69,12 @@ export type SubagentThreadViewMessageRecord = Pick< | 'text' | 'createdAt' | 'error' + | 'subagentTranscript' | 'subagentTask' -> & { textProjectionTruncated?: boolean }; +> & { + textProjectionTruncated?: boolean; + subagentTranscriptProjectionTruncated?: boolean; +}; export interface MessageMethods { saveMessage( @@ -127,6 +139,7 @@ export interface MessageMethods { tenantId?: string; limit: number; textCodePointLimit: number; + taskId?: string; }): Promise; getMessage(params: { user: string; messageId: string }): Promise; getMessagesByCursor( @@ -745,9 +758,25 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa tenantId?: string; limit: number; textCodePointLimit: number; + taskId?: string; }): Promise { try { const Message = mongoose.models.Message as Model; + const selectedAssistantMessageId = + input.taskId == null ? undefined : `${input.taskId}:assistant`; + const transcriptJsonBytes = { + $strLenBytes: { + $convert: { + input: '$subagentTranscript.messagesJson', + to: 'string', + onError: '', + onNull: '', + }, + }, + }; + const transcriptIsString = { + $eq: [{ $type: '$subagentTranscript.messagesJson' }, 'string'], + }; return await Message.aggregate([ { $match: { @@ -756,10 +785,27 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa ...(input.tenantId == null ? { tenantId: { $exists: false } } : { tenantId: input.tenantId }), + ...(input.taskId == null + ? {} + : { + messageId: { + $in: [`${input.taskId}:user`, `${input.taskId}:assistant`], + }, + }), }, }, { $sort: { createdAt: -1, _id: -1 } }, { $limit: input.limit }, + ...(input.taskId == null + ? [] + : [ + { + $set: { + _subagentTranscriptSourceBytes: transcriptJsonBytes, + _subagentTranscriptSourceIsString: transcriptIsString, + }, + }, + ]), { $project: { _id: 0, @@ -774,6 +820,57 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa }, createdAt: 1, error: 1, + ...(input.taskId == null + ? {} + : { + subagentTranscript: { + $cond: [ + { + $and: [ + { $eq: ['$messageId', selectedAssistantMessageId] }, + '$_subagentTranscriptSourceIsString', + { + $lte: [ + '$_subagentTranscriptSourceBytes', + SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT, + ], + }, + ], + }, + { + taskId: '$subagentTranscript.taskId', + mode: '$subagentTranscript.mode', + messagesJson: '$subagentTranscript.messagesJson', + }, + '$$REMOVE', + ], + }, + subagentTranscriptProjectionTruncated: { + $cond: [ + { + $and: [ + { $eq: ['$messageId', selectedAssistantMessageId] }, + { + $ne: [{ $type: '$subagentTranscript.messagesJson' }, 'missing'], + }, + { + $or: [ + { $eq: ['$_subagentTranscriptSourceIsString', false] }, + { + $gt: [ + '$_subagentTranscriptSourceBytes', + SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT, + ], + }, + ], + }, + ], + }, + true, + '$$REMOVE', + ], + }, + }), subagentTask: 1, }, }, diff --git a/packages/data-schemas/src/methods/share.test.ts b/packages/data-schemas/src/methods/share.test.ts index fbd11894456..4f756345939 100644 --- a/packages/data-schemas/src/methods/share.test.ts +++ b/packages/data-schemas/src/methods/share.test.ts @@ -2794,11 +2794,15 @@ describe('Share Methods', () => { expect(result?.updatedAt?.getTime()).toBe(published?.updatedAt?.getTime()); }); - test('does not snapshot transient text-source files', async () => { + test('snapshots database-backed text-source files without embedding their text', async () => { const userId = new mongoose.Types.ObjectId().toString(); const conversationId = `conv_${nanoid()}`; await seedConversation(userId, conversationId); - const textId = await createFile(userId, { source: 'text' }); + const textId = await createFile(userId, { + source: 'text', + filepath: 'mistral_ocr', + text: 'Extracted text', + }); await Message.create({ messageId: `msg_${nanoid()}`, conversationId, @@ -2810,7 +2814,20 @@ describe('Share Methods', () => { const result = await shareMethods.createSharedLink(userId, conversationId); const saved = await SharedLink.findOne({ shareId: result.shareId }).lean(); - expect(saved?.fileSnapshots ?? []).toHaveLength(0); + expect(saved?.fileSnapshots).toHaveLength(1); + expect(saved?.fileSnapshots?.[0]).toMatchObject({ + file_id: textId, + source: 'text', + filepath: 'mistral_ocr', + }); + expect(saved?.fileSnapshots?.[0]).not.toHaveProperty('text'); + + const shared = await shareMethods.getSharedMessages(result.shareId); + expect(shared?.messages[0].files?.[0]).toMatchObject({ + file_id: textId, + source: 'text', + filepath: `/api/share/${result.shareId}/files/${textId}`, + }); }); test('updateSharedLink clears snapshots when snapshotFiles is disabled', async () => { diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts index 87d470efc16..73f1640a860 100644 --- a/packages/data-schemas/src/methods/share.ts +++ b/packages/data-schemas/src/methods/share.ts @@ -135,8 +135,8 @@ function sanitizeSharedAttachments(attachments: unknown): t.SharedFile[] | undef * stream with only `storageKey`/`filepath` + the request. Sources requiring * owner-specific credentials (openai/azure assistants, execute_code, vectordb, * OCR/parser pipelines) are skipped — those files degrade to a 404 in the share - * view. `FileSources.text` is intentionally excluded: its `filepath` is a Multer - * temp path that the upload route deletes, so there is nothing durable to stream. + * view. Text-source files are eligible because the share route serves their + * database-backed extracted text instead of the deleted Multer temp path. */ const SNAPSHOT_STREAMABLE_SOURCES = new Set([ FileSources.local, @@ -144,6 +144,7 @@ const SNAPSHOT_STREAMABLE_SOURCES = new Set([ FileSources.cloudfront, FileSources.azure_blob, FileSources.firebase, + FileSources.text, ]); /** Collect `file_id`s from a message's `files`/`attachments` array into `target`. */ @@ -358,11 +359,18 @@ function applyShareFileRoute( file: t.SharedFile, shareId: string, snapshotIds: Set, + textSourceIds?: Set, ): t.SharedFile { const fileId = file.file_id; if (typeof fileId === 'string' && snapshotIds.has(fileId)) { const route = shareFileRoute(shareId, fileId); - const next: t.SharedFile = { ...file, filepath: route }; + const next: t.SharedFile = { + ...file, + filepath: route, + // General storage sources stay private, but `text` is a render semantic: + // clients must preview the database-backed payload as text, not the original MIME. + ...(textSourceIds?.has(fileId) && { source: FileSources.text }), + }; if (file.preview !== undefined) { next.preview = route; } @@ -390,6 +398,7 @@ export function anonymizeSharedContent( newMessageId: string; shareId: string; snapshotIds: Set; + textSourceIds?: Set; includeFiles: boolean; sanitizeUIResourceMarkers?: boolean; }, @@ -420,6 +429,7 @@ export function anonymizeSharedContent( }, params.shareId, params.snapshotIds, + params.textSourceIds, ), ) : undefined; @@ -456,6 +466,7 @@ function anonymizeMessages( newConvoId: string, shareId: string, snapshotIds: Set, + textSourceIds: Set, includeFiles: boolean, anonymizeMessageId: (id: string) => string, anonymizeAssistantId: (id: string) => string, @@ -481,6 +492,7 @@ function anonymizeMessages( }, shareId, snapshotIds, + textSourceIds, ), ) : undefined; @@ -496,6 +508,7 @@ function anonymizeMessages( }, shareId, snapshotIds, + textSourceIds, ), ) : undefined; @@ -517,6 +530,7 @@ function anonymizeMessages( newMessageId, shareId, snapshotIds, + textSourceIds, includeFiles, sanitizeUIResourceMarkers: message.isCreatedByUser !== true, }), @@ -830,6 +844,13 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { const snapshotIds = includeFiles ? new Set((fileSnapshots ?? []).map((snapshot) => snapshot.file_id)) : new Set(); + const textSourceIds = includeFiles + ? new Set( + (fileSnapshots ?? []) + .filter((snapshot) => snapshot.source === FileSources.text) + .map((snapshot) => snapshot.file_id), + ) + : new Set(); const result: t.SharedMessagesResult = { shareId: resolvedShareId, title: share.title, @@ -841,6 +862,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { newConvoId, resolvedShareId, snapshotIds, + textSourceIds, includeFiles, anonymizeMessageId, anonymizeAssistantId,