diff --git a/.github/workflows/codegraph-select.yml b/.github/workflows/codegraph-select.yml index d76d673a8aa..9257c4bac1d 100644 --- a/.github/workflows/codegraph-select.yml +++ b/.github/workflows/codegraph-select.yml @@ -17,6 +17,10 @@ permissions: contents: read pull-requests: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: select: runs-on: ubuntu-latest diff --git a/CONTEXT.md b/CONTEXT.md index b390f49505c..62ad3558459 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -9,6 +9,7 @@ - **Agent execution host**: the protocol-neutral module that owns run admission, disconnect cancellation, provider-start fencing, and terminal settlement. Protocol implementations execute behind its callback interface; HTTP adapters retain validation and final stream rendering. - **Agent execution enrollment**: the durable, protocol-neutral lifecycle authority for an admitted Agent run. It is created under the authenticated user and tenant before user-owned initialization, rechecks the shared owner-deletion admission fence after registration, exposes the only provider abort signal, fences exact provider start, terminalizes the run, waits for every trailing usage, artifact, and stored-response write, and acknowledges provider drain last. A transient terminalization failure is reconciled after trailing writes; provider drain is never acknowledged while the exact job remains nonterminal. Delete-all holds the owner fence, drains every owner run before selecting its first persistence snapshot, and repeats both the drain and an idempotent owner-persistence sweep after any recovered fence lapse before releasing admission. Exact-conversation deletion additionally performs an unconditional idempotent cleanup over its immutable deleted-ID set because a fully drained run may leave the active index after racing the first delete; only the explicit empty result is benign, while storage failures remain fatal. Chat Completions, Responses, Channels, and future ingress adapters share this authority without moving LibreChat persistence policy into the Agents SDK. - **Agent turn execution plan**: the immutable, request-local decision compiled once after authentication, agent resolution, and tool initialization. It records the trusted turn origin, conversation lineage, pause capability, binding/action context, and the preferred checkpoint, history, or fresh state-loading strategy without executing the model or owning persistence. Checkpoint failure falls back to durable history within the same Agents lifecycle. +- **Turn delivery routing**: the per-agent, request-local value that decides how each attachment reaches the model on one turn (`provider`, `text`, or `none`). Initialization settles it once, after the provider swap and the Responses API decision, under the endpoint's own name and the media dialect its config declares. Every reader of a turn route consumes that one value rather than deriving it from the agent. A stored route is an upload-time inference that this value resolves again for the turn; a destination the user chose stands. - **Effective agent selection**: the resolved endpoint and agent identity after an enforced model spec is applied. Authorization and agent loading must consume this same identity before the Agent run envelope is initialized. - **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. - **MCP direct OpenID bearer**: an operator-trusted remote MCP credential mode that resolves the logged-in user's live OpenID access token into an Authorization header. It may replace one rejected connection after a forced session refresh, but it never replays the rejected tool invocation automatically. diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 5ec6e7db960..4835f9ad80d 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -9,6 +9,7 @@ const { sanitizeFileForTransmit, extractFileContext, getReferencedQuotes, + applyTurnDelivery, encodeAndFormatAudios, encodeAndFormatVideos, getTransactionsConfig, @@ -33,17 +34,14 @@ const { isCompactedLeaf, excludedKeys, EModelEndpoint, - mergeFileConfig, isParamEndpoint, isAgentsEndpoint, isEphemeralAgentId, supportsBalanceCheck, isBedrockDocumentType, HITL_MESSAGE_FILTER_FIELDS, - getEndpointFileConfig, stripReasoningLabelMetadata, - resolveUploadLLMDeliveryPath, - isSpeechProviderConfigured, + resolveTurnLLMDeliveryPath, resolveUseResponsesApi, } = require('librechat-data-provider'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); @@ -241,10 +239,6 @@ class BaseClient { this.currentMessages = []; /** @type {import('librechat-data-provider').VisionModes | undefined} */ this.visionMode; - /** @type {import('librechat-data-provider').FileConfig | undefined} */ - this._mergedFileConfig; - /** @type {import('librechat-data-provider').EndpointFileConfig | undefined} */ - this._endpointFileConfig; } setOptions() { @@ -831,9 +825,8 @@ class BaseClient { if (this.options.resendFiles !== false && this.authorizedHistoricalFiles == null) { const historicalFileState = collectModelBoundHistoricalFileIdState(modelBoundStoredMessages); this.modelBoundHistoricalFileIdsOverflowed ||= historicalFileState.overflowed; - const files = await getOwnerHistoricalFiles( - historicalFileState.fileIds, - this.options.req?.user, + const files = this.resolveTurnAttachments( + await getOwnerHistoricalFiles(historicalFileState.fileIds, this.options.req?.user), ); this.authorizedHistoricalFiles = new Map( files @@ -1780,8 +1773,8 @@ class BaseClient { * @param {MongoFile[]} attachments - Array of file attachments * @returns {Promise} */ - async addFileContextToMessage(message, attachments) { - const textAttachments = this.getTextContextAttachments(attachments); + async addFileContextToMessage(message, attachments, fileConsumers) { + const textAttachments = this.getTextContextAttachments(attachments, fileConsumers); const fileContext = await extractFileContext({ attachments: textAttachments, req: this.options?.req, @@ -1793,9 +1786,9 @@ class BaseClient { } } - getTextContextAttachments(attachments) { + getTextContextAttachments(attachments, fileConsumers) { return attachments.filter((file) => { - const deliveryPath = this.getAttachmentDeliveryPath(file); + const deliveryPath = this.getAttachmentDeliveryPath(file, fileConsumers); /* Records predating delivery paths keep legacy extraction. Current routing is * authoritative for inferred uploads, so native provider bytes are not also * injected as extracted text after a provider handoff. */ @@ -1803,35 +1796,19 @@ class BaseClient { }); } - /** Re-resolves an inferred upload route against the provider handling this turn. */ - getAttachmentDeliveryPath(file) { - if (!this._mergedFileConfig) { - this._mergedFileConfig = mergeFileConfig(this.options.req?.config?.fileConfig); - /* Agent file policy is configured under the endpoint it names, not the client - * family initialization may rewrite it to. */ - const agentEndpoint = this.options.agent?.endpoint ?? this.options.agent?.provider; - this._deliveryEndpoint = agentEndpoint ?? this.options.endpoint; - this._endpointFileConfig = getEndpointFileConfig({ - fileConfig: this._mergedFileConfig, - endpoint: this._deliveryEndpoint, - endpointType: agentEndpoint != null ? undefined : this.options.endpointType, - }); - } + /** The turn's view of stored records, applied before admission at every load. */ + resolveTurnAttachments(files, fileConsumers = this.options.agent?.fileConsumers) { + return applyTurnDelivery(files, { + routing: this.options.agent?.deliveryRouting, + consumers: fileConsumers, + }); + } - return file.llmDeliveryPath == null || file.metadata?.destinationChosen === true - ? file.llmDeliveryPath - : resolveUploadLLMDeliveryPath({ - /* Conversion changes the stored type, so use the type routing originally saw. */ - mimeType: file.metadata?.routingMimeType ?? file.type, - endpointConfig: this._endpointFileConfig, - fileConfig: this._mergedFileConfig, - endpoint: this._deliveryEndpoint, - useResponsesApi: this.usesResponsesApi(), - sttConfigured: isSpeechProviderConfigured(this.options.req?.config?.speech?.stt), - }); + getAttachmentDeliveryPath(file, fileConsumers = this.options.agent?.fileConsumers) { + return resolveTurnLLMDeliveryPath(this.options.agent?.deliveryRouting, file, fileConsumers); } - async processAttachments(message, attachments) { + async processAttachments(message, attachments, fileConsumers) { const categorizedAttachments = { images: [], videos: [], @@ -1842,6 +1819,7 @@ class BaseClient { const allFiles = []; const provider = this.options.agent?.provider ?? this.options.endpoint; const isBedrock = provider === EModelEndpoint.bedrock; + const deliveryRouting = this.options.agent?.deliveryRouting; /* The stored path records what upload time inferred from the endpoint it saw, and this * turn may be running somewhere else: audio stored as `provider` under Google reaches @@ -1855,7 +1833,7 @@ class BaseClient { allFiles.push(file); continue; } - const deliveryPath = this.getAttachmentDeliveryPath(file); + const deliveryPath = this.getAttachmentDeliveryPath(file, fileConsumers); if (deliveryPath === 'text' || deliveryPath === 'none') { allFiles.push(file); continue; @@ -1890,9 +1868,11 @@ class BaseClient { allFiles.push(file); } else if ( file.type && - this._mergedFileConfig && - this._endpointFileConfig?.supportedMimeTypes && - this._mergedFileConfig.checkType(file.type, this._endpointFileConfig.supportedMimeTypes) + deliveryRouting?.endpointConfig.supportedMimeTypes && + deliveryRouting.fileConfig.checkType( + file.type, + deliveryRouting.endpointConfig.supportedMimeTypes, + ) ) { categorizedAttachments.documents.push(file); allFiles.push(file); @@ -1954,9 +1934,8 @@ class BaseClient { const historicalFileState = collectModelBoundHistoricalFileIdState(_messages); this.modelBoundHistoricalFileIdsOverflowed ||= historicalFileState.overflowed; const authorizedFilesById = new Map(); - const files = await getOwnerHistoricalFiles( - historicalFileState.fileIds, - this.options.req?.user, + const files = this.resolveTurnAttachments( + await getOwnerHistoricalFiles(historicalFileState.fileIds, this.options.req?.user), ); const nonSteerReplayFileIds = collectModelBoundHistoricalFileIdState( _messages.map((message) => ({ diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index 26d8f3ccfb6..53c22c33dc0 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -1,6 +1,11 @@ const { Constants, ContentTypes, EModelEndpoint } = require('librechat-data-provider'); const BaseClientClass = require('../BaseClient'); -const { ContentFilterError } = require('@librechat/api'); +const { + ContentFilterError, + resolveTurnDeliveryRouting, + buildSteerMedia, + Tokenizer, +} = require('@librechat/api'); const { FakeClient, initializeFakeClient } = require('./FakeClient'); function deferred() { @@ -1174,6 +1179,69 @@ describe('BaseClient', () => { expect(TestClient.buildMessages).toHaveBeenCalled(); }); + test('keeps the turn view of historical files that projection and steer replay read', async () => { + const routedCsv = { + file_id: 'csv-file', + filename: 'sales.csv', + filepath: '/uploads/sales.csv', + type: 'text/csv', + text: 'region,total', + llmDeliveryPath: 'none', + metadata: { destinationChosen: false }, + user: 'user-1', + }; + getFiles.mockReset(); + getFiles.mockResolvedValueOnce([routedCsv]); + TestClient = initializeFakeClient( + apiKey, + { + ...options, + agent: { + provider: EModelEndpoint.openAI, + fileConsumers: { executeCode: false, fileSearch: false }, + }, + req: { + user: { id: 'user-1', tenantId: 'tenant-a' }, + config: { + fileConfig: { + endpoints: { + [EModelEndpoint.openAI]: { + defaultLLMDeliveryPath: { overrides: { 'text/csv': 'none' } }, + textFallbackWithoutTools: true, + }, + }, + }, + }, + }, + }, + [ + { + role: 'user', + isCreatedByUser: true, + text: 'Summarize my sheet', + files: [{ file_id: 'csv-file' }], + messageId: 'historical-csv-message', + parentMessageId: Constants.NO_PARENT, + }, + ], + ); + + TestClient.options.agent.deliveryRouting = resolveTurnDeliveryRouting({ + agent: TestClient.options.agent, + config: TestClient.options.req.config, + }); + await TestClient.sendMessage('And the totals?', { + conversationId: 'historical-csv-conversation', + parentMessageId: 'historical-csv-message', + }); + + expect(TestClient.authorizedHistoricalFiles.get('csv-file')).toEqual({ + ...routedCsv, + llmDeliveryPath: 'text', + }); + expect(routedCsv.llmDeliveryPath).toBe('none'); + }); + test('does not block a missing historical file omitted from the final payload', async () => { getFiles.mockReset(); getFiles.mockResolvedValueOnce([]); @@ -2580,6 +2648,110 @@ describe('BaseClient', () => { TestClient.checkVisionRequest = jest.fn(); }); + describe('tool-routed files on a later turn', () => { + const routedCsv = { + file_id: 'csv-file', + filename: 'sales.csv', + filepath: '/uploads/sales.csv', + source: 'local', + type: 'text/csv', + user: 'user-1', + text: 'region,total', + llmDeliveryPath: 'none', + metadata: { destinationChosen: false }, + }; + + const replayCsv = async ( + fileConsumers, + endpointConfig = { + defaultLLMDeliveryPath: { overrides: { 'text/csv': 'none' } }, + textFallbackWithoutTools: true, + }, + ) => { + getFiles.mockResolvedValueOnce([routedCsv]); + TestClient.options.req.config = { + fileConfig: { endpoints: { [EModelEndpoint.openAI]: endpointConfig } }, + }; + TestClient.options.agent = { provider: EModelEndpoint.openAI, fileConsumers }; + TestClient.options.agent.deliveryRouting = resolveTurnDeliveryRouting({ + agent: TestClient.options.agent, + config: TestClient.options.req?.config, + }); + TestClient.assertHistoricalAttachmentLimits = jest.fn(async (files) => files); + const [message] = await TestClient.addPreviousAttachments([ + { messageId: 'msg-csv', text: 'Summarize it', files: [{ file_id: 'csv-file' }] }, + ]); + return message; + }; + + test('replays the stored text when this turn runs no tool that can read the file', async () => { + const message = await replayCsv({ executeCode: false, fileSearch: false }); + const replayed = { ...routedCsv, llmDeliveryPath: 'text' }; + + expect(TestClient.assertHistoricalAttachmentLimits).toHaveBeenCalledWith([replayed]); + expect(TestClient.addFileContextToMessage).toHaveBeenCalledWith(message, [replayed]); + expect(TestClient.authorizedHistoricalFiles.get('csv-file')).toEqual(replayed); + expect(message.fileContext).toBe('region,total'); + expect(routedCsv.llmDeliveryPath).toBe('none'); + }); + + test('replays the stored text once the endpoint routes the type to text', async () => { + const message = await replayCsv( + { executeCode: true, fileSearch: false }, + { defaultLLMDeliveryPath: { overrides: { 'text/csv': 'text' } } }, + ); + const replayed = { ...routedCsv, llmDeliveryPath: 'text' }; + + expect(TestClient.assertHistoricalAttachmentLimits).toHaveBeenCalledWith([replayed]); + expect(TestClient.addFileContextToMessage).toHaveBeenCalledWith(message, [replayed]); + expect(message.fileContext).toBe('region,total'); + }); + + test('admits a historical tool-routed file this turn sends to the provider', async () => { + const routedImage = { + file_id: 'image-file', + filename: 'chart.png', + filepath: '/uploads/chart.png', + source: 'local', + type: 'image/png', + user: 'user-1', + llmDeliveryPath: 'none', + metadata: { destinationChosen: false }, + }; + getFiles.mockResolvedValueOnce([routedImage]); + TestClient.options.req.config = { fileConfig: { endpoints: {} } }; + TestClient.options.agent = { + provider: EModelEndpoint.openAI, + fileConsumers: { executeCode: false, fileSearch: false }, + }; + TestClient.options.agent.deliveryRouting = resolveTurnDeliveryRouting({ + agent: TestClient.options.agent, + config: TestClient.options.req?.config, + }); + TestClient.assertHistoricalAttachmentLimits = jest.fn(async (files) => files); + + await TestClient.addPreviousAttachments([ + { + messageId: 'msg-image', + text: 'What does it show?', + files: [{ file_id: 'image-file' }], + }, + ]); + + expect(TestClient.assertHistoricalAttachmentLimits).toHaveBeenCalledWith([ + { ...routedImage, llmDeliveryPath: 'provider' }, + ]); + }); + + test('keeps the file off the prompt when this turn can read it with code', async () => { + const message = await replayCsv({ executeCode: true, fileSearch: false }); + + expect(TestClient.assertHistoricalAttachmentLimits).toHaveBeenCalledWith([]); + expect(TestClient.addFileContextToMessage).not.toHaveBeenCalled(); + expect(message.fileContext).toBeUndefined(); + }); + }); + test('rehydrates historical file refs from owner-scoped DB rows only', async () => { getFiles.mockResolvedValueOnce([ownerFile]); @@ -3192,8 +3364,6 @@ describe('BaseClient', () => { TestClient.options = { endpoint: EModelEndpoint.openAI, }; - TestClient._mergedFileConfig = undefined; - TestClient._endpointFileConfig = undefined; TestClient.addImageURLs = jest.fn(async (message, files) => { message.image_urls = ['encoded-image']; return files; @@ -3207,9 +3377,18 @@ describe('BaseClient', () => { TestClient.addAudios = jest.fn(async (_message, files) => files); }); - /* The stored path is an upload-time inference, so delivery re-resolves it for the - * endpoint running the turn. A test asserting a route has to configure that route - * rather than rely on the stored value alone. */ + /** The routing initialization settles for an agent, from the request config it reads. */ + const routedAgent = (agent) => ({ + ...agent, + deliveryRouting: resolveTurnDeliveryRouting({ + agent, + config: TestClient.options.req?.config, + }), + }); + + /* The stored path is an upload-time inference, so delivery resolves it again by the + * routing settled for the agent running the turn. A test asserting a route has to + * configure that route rather than rely on the stored value alone. */ const routeTo = (path, ...mimeTypes) => { TestClient.options.req = { config: { @@ -3224,8 +3403,10 @@ describe('BaseClient', () => { }, }, }; - TestClient._mergedFileConfig = undefined; - TestClient._endpointFileConfig = undefined; + TestClient.options.agent = routedAgent({ + provider: EModelEndpoint.openAI, + endpoint: EModelEndpoint.openAI, + }); }; test('keeps a none image in returned files without adding image URLs', async () => { @@ -3262,6 +3443,144 @@ describe('BaseClient', () => { expect(TestClient.getTextContextAttachments([file])).toEqual([]); }); + const routeCsvToTools = ({ textFallbackWithoutTools = true } = {}) => { + routeTo('none', 'text/csv'); + TestClient.options.req.config.fileConfig.endpoints[ + EModelEndpoint.openAI + ].textFallbackWithoutTools = textFallbackWithoutTools; + }; + + test('injects the text stored for a tool-routed file when this turn runs no reader', () => { + routeCsvToTools(); + TestClient.options.agent = { + provider: EModelEndpoint.openAI, + fileConsumers: { executeCode: false, fileSearch: false }, + }; + TestClient.options.agent.deliveryRouting = resolveTurnDeliveryRouting({ + agent: TestClient.options.agent, + config: TestClient.options.req?.config, + }); + /* Agent initialization marks the copy it hands this client, so the stored route reads + * `text` while the configured route stays `none`. */ + const file = { + file_id: 'fallback-csv', + filename: 'sales.csv', + type: 'text/csv', + text: 'region,total', + llmDeliveryPath: 'text', + metadata: { destinationChosen: false }, + }; + + expect(TestClient.getAttachmentDeliveryPath(file)).toBe('text'); + expect(TestClient.getTextContextAttachments([file])).toEqual([file]); + }); + + test('delivers a late steer through fallback even when the initialized agent has file tools', async () => { + routeCsvToTools(); + TestClient.options.agent = routedAgent({ + provider: EModelEndpoint.openAI, + fileConsumers: { executeCode: true, fileSearch: true }, + }); + const file = { + file_id: 'late-csv', + filename: 'late.csv', + type: 'text/csv', + source: 'local', + text: 'region,total', + llmDeliveryPath: 'none', + metadata: { destinationChosen: false }, + }; + const assertFilesAllowed = jest.fn(); + const initEncoding = jest.spyOn(Tokenizer, 'initEncoding').mockResolvedValue(undefined); + const getTokenCount = jest.spyOn(Tokenizer, 'getTokenCount').mockReturnValue(4); + let result; + try { + result = await buildSteerMedia({ + client: { + resolveTurnAttachments: TestClient.resolveTurnAttachments.bind(TestClient), + addFileContextToMessage: + BaseClientClass.prototype.addFileContextToMessage.bind(TestClient), + processAttachments: BaseClientClass.prototype.processAttachments.bind(TestClient), + }, + user: { id: 'user-1' }, + item: { steerId: 'late', text: 'Read this file', files: [{ file_id: file.file_id }] }, + getFiles: jest.fn().mockResolvedValue([file]), + assertFilesAllowed, + }); + } finally { + initEncoding.mockRestore(); + getTokenCount.mockRestore(); + } + expect(assertFilesAllowed).toHaveBeenCalledWith([{ ...file, llmDeliveryPath: 'text' }]); + expect(JSON.stringify(result.content)).toContain('region,total'); + expect(file.llmDeliveryPath).toBe('none'); + expect(TestClient.options.agent.fileConsumers).toEqual({ + executeCode: true, + fileSearch: true, + }); + }); + + test('keeps a tool-routed file off the prompt when this turn can read it with code', () => { + routeCsvToTools(); + TestClient.options.agent = { + provider: EModelEndpoint.openAI, + fileConsumers: { executeCode: true, fileSearch: false }, + }; + TestClient.options.agent.deliveryRouting = resolveTurnDeliveryRouting({ + agent: TestClient.options.agent, + config: TestClient.options.req?.config, + }); + const file = { + file_id: 'code-csv', + filename: 'sales.csv', + type: 'text/csv', + text: 'region,total', + llmDeliveryPath: 'none', + metadata: { destinationChosen: false }, + }; + + expect(TestClient.getAttachmentDeliveryPath(file)).toBe('none'); + expect(TestClient.getTextContextAttachments([file])).toEqual([]); + }); + + test('does not fall back on an endpoint that has not enabled it', () => { + routeCsvToTools({ textFallbackWithoutTools: false }); + TestClient.options.agent = { + provider: EModelEndpoint.openAI, + fileConsumers: { executeCode: false, fileSearch: false }, + }; + TestClient.options.agent.deliveryRouting = resolveTurnDeliveryRouting({ + agent: TestClient.options.agent, + config: TestClient.options.req?.config, + }); + const file = { + file_id: 'disabled-csv', + filename: 'sales.csv', + type: 'text/csv', + text: 'region,total', + llmDeliveryPath: 'text', + metadata: { destinationChosen: false }, + }; + + expect(TestClient.getAttachmentDeliveryPath(file)).toBe('none'); + expect(TestClient.getTextContextAttachments([file])).toEqual([]); + }); + + test('does not fall back when the turn tools are unknown', () => { + routeCsvToTools(); + TestClient.options.agent = { provider: EModelEndpoint.openAI }; + const file = { + file_id: 'unknown-csv', + filename: 'sales.csv', + type: 'text/csv', + text: 'region,total', + llmDeliveryPath: 'none', + metadata: { destinationChosen: false }, + }; + + expect(TestClient.getTextContextAttachments([file])).toEqual([]); + }); + test('does not inject extracted text when the current provider resolves native delivery', () => { routeTo('provider', 'application/pdf'); const file = { @@ -3327,17 +3646,19 @@ describe('BaseClient', () => { expect(TestClient.addImageURLs).not.toHaveBeenCalled(); }); - test('reads the Responses setting from a plain conversation too', async () => { - /* A non-agent Azure chat carries it in model options, and reading only the agent - * parameters re-resolves a natively supported PDF to text, which the record has - * none of, so the model receives nothing. */ + test('reads the Responses setting the turn runs on from the settled routing', async () => { + /* Azure sends a PDF natively only under the Responses API. The routing carries the + * decision initialization made, so a record stored as `provider` is not resolved + * again to text it has none of, which would leave the model with nothing. */ TestClient.options = { - endpoint: EModelEndpoint.azureOpenAI, + endpoint: EModelEndpoint.agents, req: { config: { fileConfig: undefined } }, }; - TestClient.modelOptions = { useResponsesApi: true }; - TestClient._mergedFileConfig = undefined; - TestClient._endpointFileConfig = undefined; + TestClient.options.agent = routedAgent({ + provider: EModelEndpoint.azureOpenAI, + endpoint: EModelEndpoint.azureOpenAI, + model_parameters: { useResponsesApi: true }, + }); const message = {}; const file = { user: 'user1', @@ -3353,7 +3674,6 @@ describe('BaseClient', () => { await TestClient.processAttachments(message, [file]); expect(TestClient.addDocuments).toHaveBeenCalled(); - TestClient.modelOptions = undefined; }); test('resolves a custom endpoint policy by the name the admin configured', async () => { @@ -3377,8 +3697,7 @@ describe('BaseClient', () => { }, }, }; - TestClient._mergedFileConfig = undefined; - TestClient._endpointFileConfig = undefined; + TestClient.options.agent = routedAgent(TestClient.options.agent); const message = {}; const file = { user: 'user1', @@ -3418,8 +3737,7 @@ describe('BaseClient', () => { }, }, }; - TestClient._mergedFileConfig = undefined; - TestClient._endpointFileConfig = undefined; + TestClient.options.agent = routedAgent(TestClient.options.agent); const message = {}; const file = { user: 'user1', diff --git a/api/package.json b/api/package.json index 5f38267f31b..1149d87d39c 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "5.1.6", - "@librechat/agents": "^3.8.6", + "@librechat/agents": "^3.8.7", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 4ae39e90a19..0176ad23ce1 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -137,6 +137,7 @@ const { isAttachmentObjectNotFoundError, buildAgentScopedContext, buildAgentScopedAttachmentMap, + resolveScopedTurnAttachments, buildAgentContextAttachmentsByAgentId, buildSkillPrimeContentParts, buildInitialToolSessions, @@ -389,7 +390,7 @@ class AgentClient extends BaseClient { } } - async processAttachments(message, attachments) { + async processAttachments(message, attachments, fileConsumers) { const modelBoundAttachments = this.getModelBoundAttachmentsForEndpoint(attachments); const processableAttachments = this.getProcessableAttachmentsForEndpoint( attachments, @@ -409,7 +410,7 @@ class AgentClient extends BaseClient { }; logAgentMemorySnapshot('before_process_attachments', memoryContext); try { - return await super.processAttachments(message, processableAttachments); + return await super.processAttachments(message, processableAttachments, fileConsumers); } finally { logAgentMemorySnapshot('after_process_attachments', memoryContext); } @@ -2374,6 +2375,16 @@ class AgentClient extends BaseClient { ...modelBoundRequestAttachments, ]; const sharedRunAttachmentIds = collectFileIds(sharedAttachmentFiles); + this.options.agentContextAttachmentsByAgentId = resolveScopedTurnAttachments({ + agents: allAgents, + sharedConversationAgentIds: [this.options.agent.id, ...(this.agentConfigs?.keys() ?? [])], + resendFiles: this.options.resendFiles, + messages: orderedMessages, + historicalFiles: this.authorizedHistoricalFiles, + requestAttachments, + sharedRunAttachmentIds, + attachmentsByAgentId: this.options.agentContextAttachmentsByAgentId, + }); const scopedAttachmentMap = buildAgentScopedAttachmentMap({ agentIds: allAgents.map(({ agentId }) => agentId), attachmentsByAgentId: this.options.agentContextAttachmentsByAgentId, diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 7a986a19cf0..852487c2de9 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -5471,6 +5471,9 @@ describe('AgentClient - titleConvo', () => { }, }; mockRes = {}; + mockAgent.deliveryRouting = jest + .requireActual('@librechat/api') + .resolveTurnDeliveryRouting({ agent: mockAgent, config: mockReq.config }); client = new AgentClient({ req: mockReq, @@ -6708,6 +6711,71 @@ describe('AgentClient - titleConvo', () => { }, ); + it.each(['current', 'history', 'history-disabled'])( + 'resolves %s tool-routed text only for an authorized handoff without a reader', + async (location) => { + const file = { + ...makeUploadedFile('fallback-file', 'sales.csv', 'text/csv'), + text: 'handoff fallback content', + llmDeliveryPath: 'none', + metadata: { destinationChosen: false }, + }; + const { resolveTurnDeliveryRouting } = jest.requireActual('@librechat/api'); + client.options.req.config.fileConfig = { + endpoints: { + default: { + defaultLLMDeliveryPath: { overrides: { 'text/csv': 'none' } }, + textFallbackWithoutTools: true, + }, + }, + }; + mockAgent.deliveryRouting = resolveTurnDeliveryRouting({ + agent: mockAgent, + config: client.options.req.config, + }); + mockAgent.fileConsumers = { executeCode: true, fileSearch: false }; + const handoffAgent = { + id: 'handoff-agent', + endpoint: EModelEndpoint.openAI, + provider: EModelEndpoint.openAI, + instructions: 'Handoff instructions', + model_parameters: { model: 'gpt-4' }, + tools: [], + deliveryRouting: mockAgent.deliveryRouting, + fileConsumers: { executeCode: false, fileSearch: false }, + }; + const isolatedAgent = { ...handoffAgent, id: 'isolated-agent' }; + mockAgent.subagentAgentConfigs = new Map([['isolated-agent', isolatedAgent]]); + client.agentConfigs = new Map([['handoff-agent', handoffAgent]]); + client.options.resendFiles = location !== 'history-disabled'; + client.options.attachments = location === 'current' ? [file] : []; + client.authorizedHistoricalFiles = new Map([[file.file_id, file]]); + client.message_file_map = {}; + const messages = [ + { + messageId: 'msg-1', + sender: 'User', + text: 'Read it', + isCreatedByUser: true, + ...(location !== 'current' ? { files: [{ file_id: file.file_id }] } : {}), + }, + ]; + const result = await client.buildMessages(messages, 'msg-1', {}); + expect(JSON.stringify(result.prompt)).not.toContain(file.text); + expect(mockAgent.additional_instructions ?? '').not.toContain(file.text); + expect(isolatedAgent.additional_instructions ?? '').not.toContain(file.text); + if (location === 'history-disabled') { + expect(handoffAgent.additional_instructions ?? '').not.toContain(file.text); + } else { + expect(handoffAgent.additional_instructions).toContain(file.text); + expect(client.turnScopedAttachmentsByAgentId.get('handoff-agent')).toEqual([ + { ...file, llmDeliveryPath: 'text' }, + ]); + } + expect(file.llmDeliveryPath).toBe('none'); + }, + ); + it('places request context inline and applies each agent context doc only once', async () => { const requestFile = makeTextFile('request-file', 'request.txt', 'Shared request context'); const primaryContext = makeTextFile( diff --git a/api/server/routes/files/files.agents.test.js b/api/server/routes/files/files.agents.test.js index 4323a28e817..45b082601d1 100644 --- a/api/server/routes/files/files.agents.test.js +++ b/api/server/routes/files/files.agents.test.js @@ -427,6 +427,33 @@ describe('File Routes - Agent Files Endpoint', () => { return testApp; }; + it.each([ + [false, 'application/json'], + [true, 'text/event-stream'], + ])( + 'rejects unsupported provider audio before persistence (legacy=%s, accept=%s)', + async (legacyFileUploadUX, accept) => { + const testApp = createAppWithUser( + otherUserId, + SystemRoles.USER, + { + fileConfig: { + endpoints: { MyGateway: { supportedMimeTypes: ['audio/.*'], legacyFileUploadUX } }, + }, + }, + { originalname: 'clip.wma', mimetype: 'audio/wma' }, + ); + const response = await request(testApp).post('/files').set('Accept', accept).send({ + endpoint: 'MyGateway', + file_id: uuidv4(), + }); + expect(response.status).toBe(415); + expect(response.body.message).toBe('com_error_files_provider_audio_format'); + expect(processAgentFileUpload).not.toHaveBeenCalled(); + expect(fs.unlink).toHaveBeenCalledWith('/tmp/test.txt'); + }, + ); + it('inspects the canonical sanitized filename used by upload processing', async () => { await createAgent({ id: agentCustomId, diff --git a/api/server/routes/files/images.js b/api/server/routes/files/images.js index 7e5512f0cd1..435027a3b0c 100644 --- a/api/server/routes/files/images.js +++ b/api/server/routes/files/images.js @@ -23,6 +23,7 @@ const { resolveUploadLLMDeliveryPath, isResponsesApiUpload, isSpeechProviderConfigured, + getCustomEndpointProvider, } = require('librechat-data-provider'); const { processAgentFileUpload, @@ -107,6 +108,7 @@ router.post('/', async (req, res) => { endpointConfig: getEndpointFileConfig({ fileConfig, endpoint: effectiveEndpoint }), fileConfig, endpoint: effectiveEndpoint, + endpointProvider: getCustomEndpointProvider(req.config?.endpoints?.custom, effectiveEndpoint), useResponsesApi: isResponsesApiUpload(metadata.useResponsesApi), sttConfigured: isSpeechProviderConfigured(req.config?.speech?.stt), }); diff --git a/api/server/services/Endpoints/agents/skillDeps.js b/api/server/services/Endpoints/agents/skillDeps.js index c8aa0f2b303..7f695bbfea0 100644 --- a/api/server/services/Endpoints/agents/skillDeps.js +++ b/api/server/services/Endpoints/agents/skillDeps.js @@ -291,10 +291,11 @@ function buildAgentToolContext({ agent, config }) { agent, fileEncodingAgent: { provider: config.provider, - endpoint: config.endpoint, model_parameters: config.model_parameters, imageDetail: config.imageDetail, agentContextAttachments: config.agentContextAttachments, + fileConsumers: config.fileConsumers, + deliveryRouting: config.deliveryRouting, }, /** Per-agent resolved endpoint token/pricing config. Retained here because * `agentToolContexts` is the one map that holds every agent — including diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index 0a5705e60c3..05b02ae6df6 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -25,6 +25,7 @@ const { isMessageFileUpload, isResponsesApiUpload, isSpeechProviderConfigured, + getCustomEndpointProvider, } = require('librechat-data-provider'); const { logger, runAsSystem } = require('@librechat/data-schemas'); const { @@ -35,7 +36,9 @@ const { assertExtractedTextInspectable, getFileExtractionLogDetails, getUploadExtractedTextPlan, + resolveUploadFallbackText, UPLOAD_EXTRACTED_TEXT_PLANS, + MAX_STORED_EXTRACTED_TEXT_BYTES, inspectContent, extractFileContent, hasActiveFileFieldPolicy, @@ -494,6 +497,7 @@ const processImageFile = async ({ req, res, metadata, returnFile = false, sseStr endpointConfig, fileConfig, endpoint: configEndpoint, + endpointProvider: getCustomEndpointProvider(appConfig?.endpoints?.custom, configEndpoint), useResponsesApi: isResponsesApiUpload(metadata.useResponsesApi ?? req.body?.useResponsesApi), sttConfigured: isSpeechProviderConfigured(appConfig?.speech?.stt), }); @@ -810,6 +814,7 @@ const processAgentFileUpload = async ({ req, res, metadata, sseStream }) => { endpointConfig, fileConfig, endpoint, + endpointProvider: getCustomEndpointProvider(appConfig?.endpoints?.custom, endpoint), useResponsesApi: isResponsesApiUpload(metadata.useResponsesApi ?? req.body?.useResponsesApi), sttConfigured: isSpeechProviderConfigured(appConfig?.speech?.stt), }); @@ -939,9 +944,9 @@ const processAgentFileUpload = async ({ req, res, metadata, sseStream }) => { }); } const textBytes = Buffer.byteLength(text, 'utf8'); - if (textBytes > 15 * megabyte) { + if (textBytes > MAX_STORED_EXTRACTED_TEXT_BYTES) { throw new Error( - `Extracted text from "${file.originalname}" exceeds the 15MB storage limit (${Math.round(textBytes / megabyte)}MB). Try a shorter document.`, + `Extracted text from "${file.originalname}" exceeds the ${MAX_STORED_EXTRACTED_TEXT_BYTES / megabyte}MB storage limit (${Math.round(textBytes / megabyte)}MB). Try a shorter document.`, ); } if ( @@ -1149,6 +1154,17 @@ const processAgentFileUpload = async ({ req, res, metadata, sseStream }) => { return await createTextFile({ text }); } + /* Extracted before storage, which may move the temporary upload the extractors read. */ + const fallbackText = await resolveUploadFallbackText({ + file, + fileId: file_id, + deliveryPath: llmDeliveryPath, + destinationChosen: uploadChoiceMetadata.destinationChosen, + isMessageAttachment: messageAttachment, + endpointConfig, + filters: appConfig?.filters, + }); + // Dual storage pattern for RAG files: Storage + Vector DB let storageResult, embeddingResult; let storedType = file.mimetype; @@ -1345,6 +1361,7 @@ const processAgentFileUpload = async ({ req, res, metadata, sseStream }) => { width, tenantId: req.user.tenantId, llmDeliveryPath, + text: fallbackText, }), ...retentionExpiry, }; diff --git a/api/server/services/Files/process.spec.js b/api/server/services/Files/process.spec.js index fbf1aca9b4e..efbb8a1b008 100644 --- a/api/server/services/Files/process.spec.js +++ b/api/server/services/Files/process.spec.js @@ -125,6 +125,9 @@ jest.mock('@librechat/api', () => { /** Grants both; these specs vary the capability set, not the role. */ resolveToolRoleGrants: jest.fn(async () => ({ runCode: true, fileSearch: true })), parseText: jest.fn().mockResolvedValue({ text: '', bytes: 0 }), + /** Stores no fallback text unless a test opts in; its own rules are covered in packages/api. */ + resolveUploadFallbackText: jest.fn(async () => undefined), + MAX_STORED_EXTRACTED_TEXT_BYTES: 15 * 1024 * 1024, processAudioFile: jest.fn(), extractInspectableFileText: jest.fn(async ({ extract }) => extract()), assertExtractedTextInspectable: jest.fn(), @@ -3190,3 +3193,104 @@ describe('filterFile endpoint resolution', () => { expect(() => filterFile({ req, image: true, isAvatar: true })).not.toThrow(); }); }); + +describe('fallback text for uploads left to tools', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRes.status.mockReturnThis(); + mockRes.json.mockReturnValue({}); + mergeFileConfig.mockReturnValue({ + ...makeFileConfig(), + endpoints: { + 'Custom Provider': { + defaultLLMDeliveryPath: { fallback: 'none' }, + textFallbackWithoutTools: true, + }, + }, + }); + }); + + const uploadCsv = (metadata) => { + const req = makeReq({ mimetype: 'text/csv', ocrConfig: null }); + req.body.endpoint = EModelEndpoint.agents; + return { + req, + upload: processAgentFileUpload({ + req, + res: mockRes, + metadata: { + agent_id: 'agent-abc', + message_file: 'true', + file_id: 'file-uuid-csv', + effectiveEndpoint: 'Custom Provider', + ...metadata, + }, + }), + }; + }; + + test('stores the text a turn without a reading tool can fall back to', async () => { + const { createFile } = require('~/models'); + const { resolveUploadFallbackText } = require('@librechat/api'); + setupStoredFileUpload(); + resolveUploadFallbackText.mockResolvedValueOnce('region,total'); + + const { req, upload } = uploadCsv(); + await upload; + + expect(resolveUploadFallbackText).toHaveBeenCalledWith( + expect.objectContaining({ + file: req.file, + fileId: 'file-uuid-csv', + deliveryPath: 'none', + destinationChosen: false, + isMessageAttachment: true, + endpointConfig: expect.objectContaining({ textFallbackWithoutTools: true }), + }), + ); + expect(createFile).toHaveBeenCalledWith( + expect.objectContaining({ llmDeliveryPath: 'none', text: 'region,total' }), + true, + ); + }); + + test('marks a legacy chooser upload as chosen, so no fallback text is extracted for it', async () => { + const { resolveUploadFallbackText } = require('@librechat/api'); + mergeFileConfig.mockReturnValue({ + ...makeFileConfig(), + endpoints: { + 'Custom Provider': { + defaultLLMDeliveryPath: { fallback: 'none' }, + textFallbackWithoutTools: true, + legacyFileUploadUX: true, + }, + }, + }); + setupStoredFileUpload(); + + const { upload } = uploadCsv(); + await upload; + + expect(resolveUploadFallbackText).toHaveBeenCalledWith( + expect.objectContaining({ destinationChosen: true, isMessageAttachment: true }), + ); + }); + + test('stores fallback text for an attachment filed under a tool a later turn may not run', async () => { + const { createFile } = require('~/models'); + const { resolveUploadFallbackText } = require('@librechat/api'); + setupStoredFileUpload(); + resolveUploadFallbackText.mockResolvedValueOnce('region,total'); + + const { upload } = uploadCsv({ agentTools: [EToolResources.execute_code] }); + await upload; + + expect(resolveUploadFallbackText).toHaveBeenCalledWith( + expect.objectContaining({ destinationChosen: false, isMessageAttachment: true }), + ); + expect(createFile).toHaveBeenCalledWith( + expect.objectContaining({ llmDeliveryPath: 'none', text: 'region,total' }), + true, + ); + }); +}); diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 6994c106d3a..3afc2ba13ef 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -2280,6 +2280,7 @@ async function loadToolsForExecution({ authHeaders, baseUrl: codeExecutionContext.baseUrl, workspaceId: codeExecutionContext.codeWorkspace.workspaceId, + environment: codeExecutionContext.codeWorkspace.environment, gitIdentity: agent?.git_identity, maxTimeoutMs: resolveAttachedWorkspaceCommandTimeoutMax( codeExecutionContext.codeEnvironmentConfigSchema, diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 2c39712cbd1..6d1ec5ce2a9 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -596,6 +596,7 @@ export interface ExtendedFile { source?: FileSources; attached?: boolean; embedded?: boolean; + llmDeliveryPath?: t.TFile['llmDeliveryPath']; tool_resource?: string; metadata?: t.TFile['metadata']; } diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index 8e127931f5b..bb8ffcb3511 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -14,6 +14,7 @@ import { useAdaptiveSSE, useChatHelpers, useQueueDrain, + useQueuedTurnReveal, useLocalize, } from '~/hooks'; import { ChatContext, AddedChatContext, ChatFormProvider, useFileMapContext } from '~/Providers'; @@ -105,8 +106,12 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje // refetch is in flight, and resume must not build from (or race) it. useResumeOnLoad(conversationId, chatHelpers.getMessages, index, !isLoading && !isFetching); + // Show a server-owned queued follow-up as the next user turn as soon as its + // predecessor completes, ahead of the receipt and active-job polls. + const revealQueuedTurn = useQueuedTurnReveal(conversationId, index); + // Auto-send queued follow-up messages once a run finishes cleanly. - useQueueDrain(index, conversationId, chatHelpers.ask); + useQueueDrain(index, conversationId, chatHelpers.ask, revealQueuedTurn); let content: JSX.Element | null | undefined; const isLandingPage = diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 6bc3a9715b4..dd0b1e8e92d 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -57,6 +57,7 @@ import FileFormChat from './Files/FileFormChat'; import InFlightSteers from './InFlightSteers'; import TextareaHeader from './TextareaHeader'; import PromptsCommand from './PromptsCommand'; +import { submitFromComposer } from './submit'; import SkillsCommand from './SkillsCommand'; import AudioRecorder from './AudioRecorder'; import AutoPlayAudio from './AutoPlayAudio'; @@ -70,6 +71,26 @@ import BadgeRow from './BadgeRow'; import Mention from './Mention'; import store from '~/store'; +export function toRestoredComposerFile( + file: NonNullable[number], +): ExtendedFile | null { + if (!file.file_id) { + return null; + } + return { + file_id: file.file_id, + filename: file.filename, + filepath: file.filepath, + type: file.type ?? '', + height: file.height, + width: file.width, + size: file.bytes ?? 0, + progress: 1, + attached: true, + llmDeliveryPath: file.llmDeliveryPath, + }; +} + interface ChatFormProps { index: number; placeholder?: string; @@ -259,7 +280,7 @@ const ChatForm = memo(function ChatForm({ * collapsed batch is neither — it hands the composer back to the thread. */ const composerReserved = answerMode.composerAnswers || answerMode.composerLocked; - useAutoSave({ + const consumeDraft = useAutoSave({ index, files, setFiles, @@ -346,20 +367,11 @@ const ChatForm = memo(function ChatForm({ setFiles((prev) => { const next = new Map(prev); for (const file of chipFiles) { - if (!file.file_id) { + const restoredFile = toRestoredComposerFile(file); + if (restoredFile == null) { continue; } - next.set(file.file_id, { - file_id: file.file_id, - filename: file.filename, - filepath: file.filepath, - type: file.type ?? '', - height: file.height, - width: file.width, - size: file.bytes ?? 0, - progress: 1, - attached: true, - }); + next.set(restoredFile.file_id, restoredFile); } return next; }); @@ -370,6 +382,7 @@ const ChatForm = memo(function ChatForm({ [methods, setFiles, restoreComposerContext], ); const steering = useSteering({ + consumeDraft, index, conversationId, conversation, @@ -602,25 +615,27 @@ const ChatForm = memo(function ChatForm({ bottomClearance = 'sm:mb-10'; } + /** Answer mode, then during-run steering or queueing (a run in flight, or a + * queued follow-up about to start), then an ordinary send: the same route + * for typed, dictated, and shortcut-bound submissions. */ + const submitComposerText = useCallback( + (data: { text: string }): false | void => + submitFromComposer( + { + answerMode, + steering, + submitMessage, + reset: () => methods.reset(), + }, + data, + ), + [answerMode, steering, submitMessage, methods], + ); + return (
{ - // Answer mode: composer text answers the paused run instead of - // starting a new turn (submitText resets the composer itself). - // Dismissing the popover — or collapsing a batch, which answers in its - // own card — restores normal sends. - if (answerMode.active && answerMode.submitText(data.text)) { - return; - } - // During a run, a submit steers or queues per the effective action - // instead of starting a new turn (which would be dropped anyway). - if (steering.duringRunActive) { - if (steering.submitDuringRun(data.text)) { - methods.reset(); - } - return; - } - return submitMessage(data); + submitComposerText(data); })} className={cn( /* `margin-bottom` is animated as well as `max-width`: it is what carries @@ -855,7 +870,7 @@ const ChatForm = memo(function ChatForm({ conversation={conversation} addedConversation={addedConvo} setConversation={setConversation} - disabled={disableInputs || isSubmitting} + disabled={disableInputs} /> {index === 0 && conversationId != null && ( @@ -865,7 +880,7 @@ const ChatForm = memo(function ChatForm({ {SpeechToText && ( diff --git a/client/src/components/Chat/Input/CodeApprovalMenu.tsx b/client/src/components/Chat/Input/CodeApprovalMenu.tsx index d4e78ec4b75..85446bf57ee 100644 --- a/client/src/components/Chat/Input/CodeApprovalMenu.tsx +++ b/client/src/components/Chat/Input/CodeApprovalMenu.tsx @@ -1,4 +1,6 @@ import * as Ariakit from '@ariakit/react'; +import { useQueryClient } from '@tanstack/react-query'; +import { QueryKeys, Constants } from 'librechat-data-provider'; import { TooltipAnchor, composerControlClasses } from '@librechat/client'; import { Check, ChevronDown, FilePen, FileQuestionMark, FileTerminal } from 'lucide-react'; import type { CodeApprovalMode, TConversation } from 'librechat-data-provider'; @@ -47,6 +49,7 @@ export default function CodeApprovalMenu({ disabled: boolean; }) { const localize = useLocalize(); + const queryClient = useQueryClient(); const { available, modes, selected } = useCodeApprovalMode(conversation, addedConversation); const menuStore = Ariakit.useMenuStore({ focusLoop: true, placement: 'top-start' }); const isOpen = menuStore.useState('open'); @@ -55,6 +58,11 @@ export default function CodeApprovalMenu({ return null; } + /** Navigation and run recovery rebuild the conversation from its detail + * cache, so the pick lands there too, seeding the record from the live + * conversation when none exists yet (the resumable transport seeds the same + * key optimistically). A chat that has no id yet keeps the pick in + * conversation state alone until the run assigns one. */ const selectMode = (mode: CodeApprovalMode) => { if (!modes.includes(mode)) { return; @@ -62,6 +70,15 @@ export default function CodeApprovalMenu({ setConversation((current) => current == null ? current : { ...current, codeApprovalMode: mode }, ); + const record = conversation; + const conversationId = record?.conversationId; + if (record == null || conversationId == null || conversationId === Constants.NEW_CONVO) { + return; + } + queryClient.setQueryData([QueryKeys.conversation, conversationId], (cached) => ({ + ...(cached ?? record), + codeApprovalMode: mode, + })); }; const SelectedIcon = modeOptions[selected].icon; diff --git a/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx b/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx index d71265f96c8..783f1f00a2e 100644 --- a/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx +++ b/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx @@ -123,6 +123,13 @@ function EnvironmentWorkspaces({ {descriptor.name && (

{descriptor.id}

)} + {(descriptor.environment?.repo || descriptor.environment?.ref) && ( +

+ {[descriptor.environment.repo, descriptor.environment.ref] + .filter(Boolean) + .join(' · ')} +

+ )} {selected && (