From a785a6196ae2e46bcd149b931011e1248ad7749d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 10 Sep 2026 08:29:47 -0400 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=93=AF=20fix:=20Propagate=20Host=20Ca?= =?UTF-8?q?ncellation=20Into=20API=20Tools=20(#15812)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Propagate host cancellation through API tool execution * Preserve selected signal ownership across every API ingress --- .../agents/__tests__/openai.spec.js | 23 +++- .../agents/__tests__/responses.unit.spec.js | 107 +++++++++++------- api/server/controllers/agents/openai.js | 12 +- api/server/controllers/agents/responses.js | 24 +++- .../api/src/agents/openai/service.spec.ts | 12 +- packages/api/src/agents/openai/service.ts | 11 +- 6 files changed, 139 insertions(+), 50 deletions(-) diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index 5d51069f26a..d6935069fce 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -1310,13 +1310,34 @@ describe('OpenAIChatCompletionController', () => { const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0]; expect(toolExecuteOptions.ordinaryToolCancellation).toBe(true); - await toolExecuteOptions.loadTools(['file_search'], 'agent-123'); + expect(toolExecuteOptions.runSignal).toBe(mockExecution.signal); + expect(toolExecuteOptions.foregroundRunId).toBe(initializeParams.requestBody.messageId); + const effectiveSignal = new AbortController().signal; + await toolExecuteOptions.loadTools( + ['file_search'], + 'agent-123', + undefined, + undefined, + effectiveSignal, + ); expect(loadToolsForExecution).toHaveBeenLastCalledWith( expect.objectContaining({ agentResourceType: ResourceType.REMOTE_AGENT, requestBody: initializeParams.requestBody, + signal: effectiveSignal, }), ); + mockExecution.abort(); + await toolExecuteOptions.loadTools( + ['file_search'], + 'agent-123', + undefined, + undefined, + undefined, + ); + expect(loadToolsForExecution).toHaveBeenLastCalledWith( + expect.objectContaining({ signal: undefined }), + ); }); it('returns 503 when an agent expects MCP tools but resolves none', async () => { diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index 7ab3cef884c..635e6099dfb 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -1867,48 +1867,73 @@ describe('createResponse controller', () => { }); describe('remote-agent file authorization', () => { - it('threads the remote-agent permission boundary through initialization and tool loading', async () => { - const { initializeAgent, createToolExecuteHandler } = require('@librechat/api'); - const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService'); - const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); - - req.config.endpoints.agents.backgroundTasks = { ordinaryToolCancellation: true }; - await createResponse(req, res); - - const [initializeParams, dbMethods] = initializeAgent.mock.calls.at(-1); - const filterParams = { - files: [{ file_id: 'owner-file', user: 'agent-owner' }], - userId: 'user-123', - role: 'USER', - agentId: 'agent-123', - }; - await dbMethods.filterFilesByAgentAccess(filterParams); - expect(filterFilesByAgentAccess).toHaveBeenLastCalledWith({ - ...filterParams, - resourceType: ResourceType.REMOTE_AGENT, - }); - - await initializeParams.loadTools({ - agentId: 'agent-123', - tools: ['file_search'], - provider: 'anthropic', - model: 'claude-3', - tool_resources: { file_search: { file_ids: ['owner-file'] } }, - }); - expect(loadAgentTools).toHaveBeenLastCalledWith( - expect.objectContaining({ agentResourceType: ResourceType.REMOTE_AGENT }), - ); + it.each([false, true])( + 'threads the remote-agent permission boundary through initialization and tool loading (stream=%s)', + async (stream) => { + const { initializeAgent, createToolExecuteHandler } = require('@librechat/api'); + const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService'); + const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); + + req.config.endpoints.agents.backgroundTasks = { ordinaryToolCancellation: true }; + req.body.stream = stream; + await createResponse(req, res); + + const [initializeParams, dbMethods] = initializeAgent.mock.calls.at(-1); + const filterParams = { + files: [{ file_id: 'owner-file', user: 'agent-owner' }], + userId: 'user-123', + role: 'USER', + agentId: 'agent-123', + }; + await dbMethods.filterFilesByAgentAccess(filterParams); + expect(filterFilesByAgentAccess).toHaveBeenLastCalledWith({ + ...filterParams, + resourceType: ResourceType.REMOTE_AGENT, + }); - const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0]; - expect(toolExecuteOptions.ordinaryToolCancellation).toBe(true); - await toolExecuteOptions.loadTools(['file_search'], 'agent-123'); - expect(loadToolsForExecution).toHaveBeenLastCalledWith( - expect.objectContaining({ - agentResourceType: ResourceType.REMOTE_AGENT, - requestBody: initializeParams.requestBody, - }), - ); - }); + await initializeParams.loadTools({ + agentId: 'agent-123', + tools: ['file_search'], + provider: 'anthropic', + model: 'claude-3', + tool_resources: { file_search: { file_ids: ['owner-file'] } }, + }); + expect(loadAgentTools).toHaveBeenLastCalledWith( + expect.objectContaining({ agentResourceType: ResourceType.REMOTE_AGENT }), + ); + + const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0]; + expect(toolExecuteOptions.ordinaryToolCancellation).toBe(true); + expect(toolExecuteOptions.runSignal).toBe(mockExecution.signal); + expect(toolExecuteOptions.foregroundRunId).toBe(initializeParams.requestBody.messageId); + const effectiveSignal = new AbortController().signal; + await toolExecuteOptions.loadTools( + ['file_search'], + 'agent-123', + undefined, + undefined, + effectiveSignal, + ); + expect(loadToolsForExecution).toHaveBeenLastCalledWith( + expect.objectContaining({ + agentResourceType: ResourceType.REMOTE_AGENT, + requestBody: initializeParams.requestBody, + signal: effectiveSignal, + }), + ); + mockExecution.abort(); + await toolExecuteOptions.loadTools( + ['file_search'], + 'agent-123', + undefined, + undefined, + undefined, + ); + expect(loadToolsForExecution).toHaveBeenLastCalledWith( + expect.objectContaining({ signal: undefined }), + ); + }, + ); }); describe('token usage recording - non-streaming', () => { diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 629fa8a5396..78f930753f5 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -803,13 +803,21 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { agent never gains sandbox access even if the admin enabled the capability globally. */ const toolExecuteOptions = { + runSignal: execution.signal, + foregroundRunId: responseId, ordinaryToolCancellation: ordinaryToolCancellationEnabled, provisionFiles: createProvisionFilesCallback({ req, agentToolContexts, resolvePrimaryAgentId: () => primaryConfig.id, }), - loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection) => { + loadTools: async ( + toolNames, + agentId, + _configurable, + callerCapabilityProjection, + runSignal, + ) => { const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {}; const result = await loadToolsForExecution({ @@ -820,7 +828,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { requestBody: mcpRequestBody, toolNames, agent: ctx.agent ?? agent, - signal: execution.signal, + signal: runSignal, toolRegistry: ctx.toolRegistry, callerCapabilityProjection, backgroundToolNames: ctx.backgroundToolNames, diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index bb16156cd6c..aa851fbb510 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -1146,13 +1146,21 @@ const executeResponse = async (envelope, { req, res }) => { // Create tool execute options for event-driven tool execution const toolExecuteOptions = { + runSignal: execution.signal, + foregroundRunId: responseId, ordinaryToolCancellation: ordinaryToolCancellationEnabled, provisionFiles: createProvisionFilesCallback({ req, agentToolContexts, resolvePrimaryAgentId: () => primaryConfig.id, }), - loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection) => { + loadTools: async ( + toolNames, + agentId, + _configurable, + callerCapabilityProjection, + runSignal, + ) => { const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {}; const result = await loadToolsForExecution({ @@ -1163,7 +1171,7 @@ const executeResponse = async (envelope, { req, res }) => { requestBody: mcpRequestBody, toolNames, agent: ctx.agent ?? agent, - signal: execution.signal, + signal: runSignal, toolRegistry: ctx.toolRegistry, callerCapabilityProjection, backgroundToolNames: ctx.backgroundToolNames, @@ -1375,13 +1383,21 @@ const executeResponse = async (envelope, { req, res }) => { }); const toolExecuteOptions = { + runSignal: execution.signal, + foregroundRunId: responseId, ordinaryToolCancellation: ordinaryToolCancellationEnabled, provisionFiles: createProvisionFilesCallback({ req, agentToolContexts, resolvePrimaryAgentId: () => primaryConfig.id, }), - loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection) => { + loadTools: async ( + toolNames, + agentId, + _configurable, + callerCapabilityProjection, + runSignal, + ) => { const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {}; const result = await loadToolsForExecution({ @@ -1392,7 +1408,7 @@ const executeResponse = async (envelope, { req, res }) => { requestBody: mcpRequestBody, toolNames, agent: ctx.agent ?? agent, - signal: execution.signal, + signal: runSignal, toolRegistry: ctx.toolRegistry, callerCapabilityProjection, backgroundToolNames: ctx.backgroundToolNames, diff --git a/packages/api/src/agents/openai/service.spec.ts b/packages/api/src/agents/openai/service.spec.ts index b06b32ba673..b9aa611fa52 100644 --- a/packages/api/src/agents/openai/service.spec.ts +++ b/packages/api/src/agents/openai/service.spec.ts @@ -255,8 +255,18 @@ describe('createAgentChatCompletion - MCP permission user propagation', () => { 'agent_test', expect.objectContaining({ requestBody: runArgs.requestBody }), undefined, - undefined, + processStream.mock.calls[0][1].signal, ); + expect(loadTools.mock.calls[0][4]).toBeInstanceOf(AbortSignal); + await runArgs.customHandlers[GraphEvents.ON_TOOL_EXECUTE].handle(GraphEvents.ON_TOOL_EXECUTE, { + toolCalls: [{ id: 'child-call', name: 'deferred_mcp_tool', args: {} }], + agentId: 'agent_test', + configurable: streamConfig.configurable, + metadata: { run_id: 'detached-child' }, + resolve, + reject, + }); + expect(loadTools.mock.calls[1][4]).toBeUndefined(); }); it('uses the root parent sentinel when chat completions omit a parent id', async () => { diff --git a/packages/api/src/agents/openai/service.ts b/packages/api/src/agents/openai/service.ts index 508967e5b69..782494635c0 100644 --- a/packages/api/src/agents/openai/service.ts +++ b/packages/api/src/agents/openai/service.ts @@ -818,7 +818,16 @@ export async function createAgentChatCompletion( // Create event handlers const eventHandlers = isStreaming && handlerConfig - ? createOpenAIHandlers(handlerConfig, deps.toolExecuteOptions) + ? createOpenAIHandlers( + handlerConfig, + deps.toolExecuteOptions == null + ? undefined + : { + ...deps.toolExecuteOptions, + runSignal: abortController.signal, + foregroundRunId: requestId, + }, + ) : {}; // Convert messages to internal format From d510b6feb6eb1a174338d3accd40fc65b02678b2 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 10 Sep 2026 11:33:11 -0400 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=9B=96=20fix:=20Restore=20Saved=20Att?= =?UTF-8?q?ached=20Workspaces=20(#15811)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Restore attached workspaces from the resolved owner conversation * Carry restored conversation through lazy tool initialization * fix: Resolve Workspace State From Effective Conversation * style: Format Conversation Regression Cases * test: Retain Real Workspace Resolver In Controller Fixtures * test: Exercise Workspace Envelope Conversation Identity --- .../request.partialDisconnect.spec.js | 1 + .../__tests__/request.resumeMetadata.spec.js | 15 +++- api/server/controllers/agents/request.js | 7 +- .../services/Endpoints/agents/initialize.js | 18 ++--- .../Endpoints/agents/initialize.spec.js | 67 +++++++++++++++-- packages/api/src/agents/conversation.spec.ts | 72 ++++++++++++++++++- packages/api/src/agents/conversation.ts | 43 +++++++++++ 7 files changed, 206 insertions(+), 17 deletions(-) diff --git a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js index 29970e0f30e..dae2a4861a1 100644 --- a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js +++ b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js @@ -64,6 +64,7 @@ jest.mock('@librechat/api', () => ({ buildMessageFiles: jest.fn(() => []), resolveTitleTiming: jest.fn(() => 'immediate'), resolveConversationAnchor: jest.requireActual('@librechat/api').resolveConversationAnchor, + resolveRunCodeWorkspaces: jest.requireActual('@librechat/api').resolveRunCodeWorkspaces, GenerationJobManager: mockGenerationJobManager, getReferencedQuotes: jest.fn(() => null), cleanupMCPRequestContext: jest.fn(), diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index 654e0ec24fb..b81a06fffac 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -273,6 +273,7 @@ jest.mock('@librechat/api', () => ({ buildMessageFiles: jest.fn(() => []), resolveTitleTiming: jest.fn(() => 'immediate'), resolveConversationAnchor: jest.requireActual('@librechat/api').resolveConversationAnchor, + resolveRunCodeWorkspaces: jest.requireActual('@librechat/api').resolveRunCodeWorkspaces, getCodeWorkspaceSelectionErrorDetails: jest.requireActual('@librechat/api').getCodeWorkspaceSelectionErrorDetails, GenerationJobManager: mockGenerationJobManager, @@ -4251,7 +4252,7 @@ describe('ResumableAgentController resume metadata', () => { ); }); - it.each(['submitted', 'persisted'])( + it.each(['submitted', 'persisted', 'overridden'])( 'preserves %s workspace selections in the runtime envelope', async (source) => { mockGenerationJobManager.claimGeneration.mockResolvedValue(wonGenerationClaim()); @@ -4259,9 +4260,12 @@ describe('ResumableAgentController resume metadata', () => { const codeWorkspaces = [{ environmentId: 'machine-a', workspaceId: 'project-b' }]; const req = { user: { id: 'user-123' }, - ...(source === 'persisted' ? { resolvedConversation: { codeWorkspaces } } : {}), + ...(source !== 'submitted' + ? { resolvedConversation: { conversationId: 'conversation-123', codeWorkspaces } } + : {}), body: { ...(source === 'submitted' ? { codeWorkspaces } : {}), + ...(source === 'overridden' ? { overrideConvoId: 'target-conversation' } : {}), text: 'Fresh submission.', messageId: 'user-msg', clientRequestId: 'req-abc', @@ -4283,9 +4287,14 @@ describe('ResumableAgentController resume metadata', () => { expect(initializeClient).toHaveBeenCalledWith( expect.objectContaining({ - requestBody: expect.objectContaining({ codeWorkspaces }), + requestBody: expect.objectContaining({ + conversationId: source === 'overridden' ? 'target-conversation' : 'conversation-123', + }), }), ); + expect(initializeClient.mock.calls[0][0].requestBody.codeWorkspaces).toEqual( + source === 'overridden' ? undefined : codeWorkspaces, + ); expect(mockCheckAndIncrementPendingRequest).toHaveBeenCalledWith('user-123'); expect(mockGenerationJobManager.createJob).toHaveBeenCalledWith( 'conversation-123', diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 4ea5ba8892d..20578cffd9d 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -29,6 +29,7 @@ const { deleteAgentCheckpoint, getAttachmentTitleText, createMCPRuntimeRequestBody, + resolveRunCodeWorkspaces, isAgentEventRetentionActive, createAgentEventActorTurn, createAgentEventActorDetachedActionLifecycle, @@ -1485,7 +1486,11 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit const mcpRequestBody = createMCPRuntimeRequestBody({ messageId: preallocatedResponseMessageId, conversationId: effectiveConversationId, - codeWorkspaces: req.body.codeWorkspaces ?? req.resolvedConversation?.codeWorkspaces, + codeWorkspaces: resolveRunCodeWorkspaces({ + conversationId: effectiveConversationId, + requestedSelections: req.body.codeWorkspaces, + conversation: req.resolvedConversation, + }), parentMessageId: editedContent != null ? preallocatedResponseMessageId : preallocatedUserMessageId, }); diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 8e8c183a0d4..f5653ec8b8e 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -2,6 +2,7 @@ const { logger } = require('@librechat/data-schemas'); const { createContentAggregator, GraphNodeKeys } = require('@librechat/agents'); const { resolveSender, + resolveRunConversation, createConcurrencyLimiter, loadSkillStates, initializeAgent, @@ -191,14 +192,13 @@ const initializeClient = async ({ /** The normal controller resolves this once for timestamp anchoring. Reuse * 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); - } else if (typeof conversationId === 'string' && conversationId !== '') { - requestConversationPromise = db.getConvo(req.user.id, conversationId); - } + const conversationId = runtimeRequestBody?.conversationId; + const requestConversationPromise = resolveRunConversation({ + request: req, + conversationId, + loadConversation: (conversationId) => db.getConvo(req.user.id, conversationId), + }); const startupTelemetry = getAgentStartupTelemetry(req); /** @type {string | null} */ @@ -556,6 +556,8 @@ const initializeClient = async ({ requestConversationPromise, toolRoleGrantsPromise, ]); + /** Preserve the owner-scoped fallback for loaders that share this request. */ + req.resolvedConversation = requestConversation; delete endpointOption.agent; /** The deployment switch AND the role grant. `initializeAgent` rebuilds @@ -1039,7 +1041,7 @@ const initializeClient = async ({ ? await resolveCodeExecutionWorkspaceContext({ context: baseCodeExecutionContext, requestedSelections: runtimeRequestBody?.codeWorkspaces, - persistedSelections: req.resolvedConversation?.codeWorkspaces, + persistedSelections: requestConversation?.codeWorkspaces, environments: configuredCodeEnvironments, getAppConfig, }) diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index bfe09ccb391..836268ab199 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -1476,9 +1476,18 @@ describe('initializeClient โ€” subagent loading', () => { expect(mockInitializeAgent).toHaveBeenCalledTimes(1); }); - it.each([true, false])( - 'validates the lazy subagent workspace before exposure: registered=%s', - async (registered) => { + it.each([ + [true, 'request'], + [false, 'request'], + [true, 'fallback'], + [true, 'override'], + [true, 'override-resolved'], + [true, 'resolved'], + [false, 'resolved-null'], + [false, 'other-owner'], + ])( + 'validates the lazy subagent workspace before exposure: registered=%s source=%s', + async (registered, source) => { const subAgent = await createAgent({ id: SUBAGENT_ID, name: 'Attached Stateful Subagent', @@ -1513,7 +1522,35 @@ describe('initializeClient โ€” subagent loading', () => { ], }; req.body.codeWorkspaces = [{ environmentId: 'attached-vm', workspaceId: 'project-b' }]; - if (!registered) req.body.codeWorkspaces[0].workspaceId = 'removed-project'; + if (source === 'request' && !registered) { + req.body.codeWorkspaces[0].workspaceId = 'removed-project'; + } + let requestBody; + if (source !== 'request') { + const conversation = await mongoose.model('Conversation').create({ + conversationId: req.body.conversationId, + endpoint: 'agents', + user: source === 'other-owner' ? new mongoose.Types.ObjectId().toString() : req.user.id, + codeWorkspaces: req.body.codeWorkspaces, + }); + delete req.body.codeWorkspaces; + if (source === 'resolved') req.resolvedConversation = conversation.toObject(); + else if (source !== 'resolved-null') delete req.resolvedConversation; + if (source.startsWith('override')) { + conversation.codeWorkspaces = [ + { environmentId: 'attached-vm', workspaceId: 'wrong-source' }, + ]; + await conversation.save(); + if (source === 'override-resolved') req.resolvedConversation = conversation.toObject(); + requestBody = { ...req.body, conversationId: 'effective-target' }; + await mongoose.model('Conversation').create({ + conversationId: requestBody.conversationId, + endpoint: 'agents', + user: req.user.id, + codeWorkspaces: [{ environmentId: 'attached-vm', workspaceId: 'project-b' }], + }); + } + } mockGetAppConfig.mockResolvedValue(req.config); process.env.TEST_LAZY_WORKSPACE_TOKEN = 'test-token'; const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue( @@ -1540,6 +1577,7 @@ describe('initializeClient โ€” subagent loading', () => { try { const initialization = initializeClient({ req, + requestBody, res: {}, signal: new AbortController().signal, endpointOption: makeEndpointOption(), @@ -1552,6 +1590,27 @@ describe('initializeClient โ€” subagent loading', () => { return; } await initialization; + if (source === 'fallback' || source.startsWith('override')) { + mockInitializeAgent.mockImplementationOnce(async (params) => { + expect(params.req.resolvedConversation.codeWorkspaces).toEqual([ + { environmentId: 'attached-vm', workspaceId: 'project-b' }, + ]); + await params.loadTools({ ...subAgent, agentId: SUBAGENT_ID }); + return makeSubagentConfig(SUBAGENT_ID); + }); + await agentClientArgs.agent.lazySubagentConfigs[0].resolve({ + signal: new AbortController().signal, + }); + expect(loadAgentTools).toHaveBeenLastCalledWith( + expect.objectContaining({ + req: expect.objectContaining({ + resolvedConversation: expect.objectContaining({ + codeWorkspaces: [{ environmentId: 'attached-vm', workspaceId: 'project-b' }], + }), + }), + }), + ); + } } finally { fetchSpy.mockRestore(); delete process.env.TEST_LAZY_WORKSPACE_TOKEN; diff --git a/packages/api/src/agents/conversation.spec.ts b/packages/api/src/agents/conversation.spec.ts index 1b912f88f8a..b70246e3923 100644 --- a/packages/api/src/agents/conversation.spec.ts +++ b/packages/api/src/agents/conversation.spec.ts @@ -1,4 +1,74 @@ -import { resolveConversationAnchor } from './conversation'; +import { + resolveRunConversation, + resolveConversationAnchor, + resolveRunCodeWorkspaces, +} from './conversation'; + +describe('resolveRunCodeWorkspaces', () => { + const saved = [{ environmentId: 'machine', workspaceId: 'source-project' }]; + const conversation = { conversationId: 'source', codeWorkspaces: saved }; + it('retains a saved selection only for the same conversation', () => { + expect(resolveRunCodeWorkspaces({ conversationId: 'source', conversation })).toBe(saved); + expect(resolveRunCodeWorkspaces({ conversationId: 'target', conversation })).toBeUndefined(); + }); + it.each([ + { requestedSelections: [] }, + { requestedSelections: [{ environmentId: 'machine', workspaceId: 'chosen-project' }] }, + ])('preserves an explicit choice, including clearing selections', ({ requestedSelections }) => { + expect( + resolveRunCodeWorkspaces({ conversationId: 'target', conversation, requestedSelections }), + ).toBe(requestedSelections); + }); +}); + +describe('resolveRunConversation', () => { + it.each([null, undefined, { conversationId: 'source' }])( + 'preserves authoritative same-conversation state: %s', + async (resolvedConversation) => { + const loadConversation = jest.fn(); + expect( + await resolveRunConversation({ + request: { body: { conversationId: 'source' }, resolvedConversation }, + conversationId: 'source', + loadConversation, + }), + ).toBe(resolvedConversation); + expect(loadConversation).not.toHaveBeenCalled(); + }, + ); + + it.each([ + {}, + { resolvedConversation: null }, + { resolvedConversation: { conversationId: 'source' } }, + ])('loads the effective target instead of source state: %s', async (state) => { + const target = { conversationId: 'target' }; + const loadConversation = jest.fn().mockResolvedValue(target); + expect( + await resolveRunConversation({ + request: { body: { conversationId: 'source' }, ...state }, + conversationId: 'target', + loadConversation, + }), + ).toBe(target); + expect(loadConversation).toHaveBeenCalledTimes(1); + expect(loadConversation).toHaveBeenCalledWith('target'); + }); + + it('does not fall back to a source when the effective conversation is absent', async () => { + const loadConversation = jest.fn(); + expect( + await resolveRunConversation({ + request: { + body: { conversationId: 'source' }, + resolvedConversation: { conversationId: 'source' }, + }, + loadConversation, + }), + ).toBeNull(); + expect(loadConversation).not.toHaveBeenCalled(); + }); +}); describe('resolveConversationAnchor', () => { const fallback = new Date('2026-07-24T12:00:00.000Z'); diff --git a/packages/api/src/agents/conversation.ts b/packages/api/src/agents/conversation.ts index a58b0b424d2..d4dfb334ef2 100644 --- a/packages/api/src/agents/conversation.ts +++ b/packages/api/src/agents/conversation.ts @@ -1,3 +1,46 @@ +import type { TConversation, CodeWorkspaceSelection } from 'librechat-data-provider'; + +/** Never turn another conversation's stored workspace into an explicit run selection. */ +export function resolveRunCodeWorkspaces({ + conversationId, + requestedSelections, + conversation, +}: { + conversationId: string; + requestedSelections?: CodeWorkspaceSelection[] | null; + conversation?: Pick | null; +}): CodeWorkspaceSelection[] | undefined { + return ( + requestedSelections ?? + (conversation?.conversationId === conversationId ? conversation.codeWorkspaces : undefined) + ); +} + +/** Reuse resolved state only for the conversation the run will actually execute. */ +export async function resolveRunConversation({ + request, + conversationId, + loadConversation, +}: { + request: { + body?: { conversationId?: string }; + resolvedConversation?: TConversation | null; + }; + conversationId?: string; + loadConversation: (conversationId: string) => Promise; +}): Promise { + if ( + conversationId === request.body?.conversationId && + Object.prototype.hasOwnProperty.call(request, 'resolvedConversation') + ) { + return request.resolvedConversation; + } + if (typeof conversationId !== 'string' || conversationId === '') { + return null; + } + return loadConversation(conversationId); +} + export interface ConversationAnchorSource { createdAt?: Date | string | number | null; } From e1cc0805f23133b70676a336e3928cdc08886fff Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 10 Sep 2026 15:12:43 -0400 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=A5=85=20fix:=20Keep=20Mobile=20Compo?= =?UTF-8?q?ser=20Actions=20In=20Bounds=20(#15825)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/Chat/Input/ChatForm.tsx | 24 +++++++++++++++---- .../Chat/Input/CodeApprovalMenu.tsx | 8 ++++--- .../Chat/Input/CodeWorkspaceMenu.tsx | 6 ++--- .../__tests__/useCodeWorkspace.test.tsx | 17 +++++++++++-- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index f13756b854b..c1e39ccea2d 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -745,6 +745,11 @@ const ChatForm = memo(function ChatForm({ onFocus={handleTextareaFocus} onBlur={handleTextareaBlur} aria-label={localize('com_ui_message_input')} + aria-describedby={ + codeWorkspace.state === 'choose' || codeWorkspace.state === 'missing' + ? `code-workspace-hint-${index}` + : undefined + } onClick={handleFocusOrClick} style={{ height: 44, overflowY: 'auto' }} className={cn( @@ -763,13 +768,22 @@ const ChatForm = memo(function ChatForm({ )} + {(codeWorkspace.state === 'choose' || codeWorkspace.state === 'missing') && ( +

+ {localize('com_error_code_workspace_required')} +

+ )}
-
+
)} -
+
{SpeechToText && ( +
methods.getValues('text')} @@ -830,7 +844,7 @@ const ChatForm = memo(function ChatForm({ />
)} -
+
{isSubmitting && (showStopButton || steering.duringRunActive) && !answerMode.composerAnswers diff --git a/client/src/components/Chat/Input/CodeApprovalMenu.tsx b/client/src/components/Chat/Input/CodeApprovalMenu.tsx index 0381ad542b8..d4e78ec4b75 100644 --- a/client/src/components/Chat/Input/CodeApprovalMenu.tsx +++ b/client/src/components/Chat/Input/CodeApprovalMenu.tsx @@ -80,7 +80,7 @@ export default function CodeApprovalMenu({ )}`} className={cn( composerControlClasses(), - 'px-2.5 md:px-theme-normal', + 'min-w-0 max-w-full px-2.5 md:px-theme-normal', isOpen && 'bg-surface-hover', disabled && 'cursor-not-allowed opacity-50', )} @@ -88,7 +88,9 @@ export default function CodeApprovalMenu({ } >