From fb07add454039ec10601f327ecd79dd31f1e96e0 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:04:44 +0200 Subject: [PATCH 01/11] =?UTF-8?q?=F0=9F=8E=92=20feat:=20Carry=20Project=20?= =?UTF-8?q?Context=20Across=20Conversations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 5 + .../agents/__tests__/callbacks.spec.js | 1 + .../agents/__tests__/modelEndHandler.spec.js | 2 +- .../agents/__tests__/openai.spec.js | 102 +- .../request.partialDisconnect.spec.js | 10 + .../__tests__/request.resumeMetadata.spec.js | 320 ++++-- .../agents/__tests__/responses.unit.spec.js | 99 +- .../agents/__tests__/resume.spec.js | 200 ++++ api/server/controllers/agents/client.js | 9 +- api/server/controllers/agents/openai.js | 87 +- api/server/controllers/agents/request.js | 53 +- api/server/controllers/agents/responses.js | 102 +- api/server/controllers/agents/resume.js | 951 ++++++++++-------- .../assistants/chat.contentFilter.spec.js | 250 ++++- api/server/controllers/assistants/chatV1.js | 42 +- api/server/controllers/assistants/chatV2.js | 51 +- api/server/routes/__tests__/config.spec.js | 21 + api/server/routes/__tests__/projects.spec.js | 151 +++ api/server/routes/config.js | 2 + api/server/routes/projects.js | 16 +- .../services/Endpoints/agents/addedConvo.js | 1 + .../services/Endpoints/agents/initialize.js | 22 +- .../Endpoints/agents/initialize.spec.js | 42 + api/server/services/Threads/manage.js | 5 +- .../services/Threads/manage.retention.spec.js | 40 +- .../__tests__/toolCapabilityGates.spec.js | 9 +- client/src/components/Chat/ChatView.tsx | 6 + .../Chat/__tests__/ChatView.spec.tsx | 7 +- .../Chat/__tests__/ChatView.subagent.spec.tsx | 17 +- .../Conversations/ProjectsSection.tsx | 29 +- .../src/components/Projects/ProjectBadge.tsx | 26 + .../components/Projects/ProjectChatList.tsx | 51 +- .../Projects/ProjectCreateDialog.tsx | 13 +- .../Projects/ProjectDeleteDialog.tsx | 7 +- .../components/Projects/ProjectEditDialog.tsx | 138 --- .../src/components/Projects/ProjectEditor.tsx | 250 +++++ .../ProjectInstructionsDialog.spec.tsx | 206 ++++ .../Projects/ProjectInstructionsDialog.tsx | 212 ++++ .../Projects/ProjectResources.spec.tsx | 327 ++++++ .../components/Projects/ProjectResources.tsx | 629 ++++++++++++ .../components/Projects/ProjectWorkspace.tsx | 553 +++++++--- .../components/Projects/ProjectsNavBar.tsx | 36 +- .../components/Projects/ProjectsSortMenu.tsx | 88 ++ .../src/components/Projects/ProjectsView.tsx | 303 +++--- .../src/data-provider/Projects/mutations.ts | 49 +- client/src/data-provider/Projects/queries.ts | 45 +- client/src/locales/en/translation.json | 40 +- client/src/utils/files.ts | 11 + e2e/setup/env.ts | 8 +- e2e/setup/fake-model.js | 55 + .../mock/content-filters.persisted.spec.ts | 221 ++++ e2e/specs/mock/enforced-model-specs.spec.ts | 3 +- e2e/specs/mock/projects.spec.ts | 205 +++- .../mock/scenarios/project-access.spec.ts | 508 ++++++++++ .../mock/scenarios/project-context.spec.ts | 293 ++++++ .../mock/scenarios/project-files-ui.spec.ts | 404 ++++++++ .../mock/scenarios/project-policy.spec.ts | 837 +++++++++++++++ .../mock/scenarios/project-resume.spec.ts | 639 ++++++++++++ eslint-suppressions.json | 21 +- librechat.example.yaml | 10 + .../src/agents/__tests__/initialize.test.ts | 533 +++++++++- packages/api/src/agents/compatibility.spec.ts | 11 + packages/api/src/agents/compatibility.ts | 6 + .../api/src/agents/conversationSymbols.ts | 6 + packages/api/src/agents/discovery.ts | 11 +- packages/api/src/agents/guard.ts | 10 +- packages/api/src/agents/hitl/policy.spec.ts | 5 +- packages/api/src/agents/hitl/policy.ts | 13 +- .../api/src/agents/initialize.files.spec.ts | 2 +- packages/api/src/agents/initialize.ts | 320 ++++-- packages/api/src/agents/runtime.ts | 28 + packages/api/src/conversations/save.ts | 8 +- packages/api/src/middleware/code.spec.ts | 143 ++- packages/api/src/projects/context.spec.ts | 256 +++++ packages/api/src/projects/context.ts | 284 ++++++ packages/api/src/projects/handlers.spec.ts | 232 +++++ packages/api/src/projects/handlers.ts | 268 ++++- packages/api/src/projects/index.ts | 4 + packages/api/src/projects/resources.spec.ts | 240 +++++ packages/api/src/projects/resources.ts | 226 +++++ packages/api/src/projects/resume.spec.ts | 176 ++++ packages/api/src/projects/resume.ts | 151 +++ packages/api/src/projects/turn.spec.ts | 260 +++++ packages/api/src/projects/turn.ts | 270 +++++ .../api/src/stream/GenerationJobManager.ts | 2 +- packages/api/src/types/http.ts | 16 +- packages/client/src/components/Button.tsx | 30 + packages/client/src/components/EmptyState.tsx | 2 +- packages/client/src/components/Input.tsx | 11 +- .../src/components/OGDialogTemplate.tsx | 25 +- packages/client/src/components/Resizable.tsx | 39 +- packages/client/src/components/Textarea.tsx | 31 +- packages/data-provider/src/api-endpoints.ts | 9 + packages/data-provider/src/config.spec.ts | 36 + packages/data-provider/src/config.ts | 56 +- packages/data-provider/src/data-service.ts | 25 + packages/data-provider/src/keys.ts | 7 + packages/data-provider/src/limits.ts | 8 +- packages/data-provider/src/types.ts | 16 + packages/data-provider/src/types/agents.ts | 5 + packages/data-provider/src/types/files.ts | 1 + packages/data-provider/src/types/queries.ts | 11 + packages/data-schemas/src/app/service.ts | 3 + packages/data-schemas/src/index.ts | 4 + .../src/methods/chatProject.spec.ts | 417 +++++++- .../data-schemas/src/methods/chatProject.ts | 282 +++++- .../src/methods/conversation.spec.ts | 69 +- .../data-schemas/src/methods/conversation.ts | 57 +- .../data-schemas/src/methods/file.spec.ts | 247 ++++- packages/data-schemas/src/methods/file.ts | 330 +++++- packages/data-schemas/src/methods/index.ts | 25 +- .../data-schemas/src/schema/chatProject.ts | 20 +- packages/data-schemas/src/schema/file.ts | 4 + packages/data-schemas/src/types/app.ts | 3 + .../data-schemas/src/types/chatProject.ts | 5 + 115 files changed, 12563 insertions(+), 1536 deletions(-) create mode 100644 api/server/routes/__tests__/projects.spec.js create mode 100644 client/src/components/Projects/ProjectBadge.tsx delete mode 100644 client/src/components/Projects/ProjectEditDialog.tsx create mode 100644 client/src/components/Projects/ProjectEditor.tsx create mode 100644 client/src/components/Projects/ProjectInstructionsDialog.spec.tsx create mode 100644 client/src/components/Projects/ProjectInstructionsDialog.tsx create mode 100644 client/src/components/Projects/ProjectResources.spec.tsx create mode 100644 client/src/components/Projects/ProjectResources.tsx create mode 100644 client/src/components/Projects/ProjectsSortMenu.tsx create mode 100644 e2e/specs/mock/scenarios/project-access.spec.ts create mode 100644 e2e/specs/mock/scenarios/project-context.spec.ts create mode 100644 e2e/specs/mock/scenarios/project-files-ui.spec.ts create mode 100644 e2e/specs/mock/scenarios/project-policy.spec.ts create mode 100644 e2e/specs/mock/scenarios/project-resume.spec.ts create mode 100644 packages/api/src/agents/conversationSymbols.ts create mode 100644 packages/api/src/projects/context.spec.ts create mode 100644 packages/api/src/projects/context.ts create mode 100644 packages/api/src/projects/handlers.spec.ts create mode 100644 packages/api/src/projects/resources.spec.ts create mode 100644 packages/api/src/projects/resources.ts create mode 100644 packages/api/src/projects/resume.spec.ts create mode 100644 packages/api/src/projects/resume.ts create mode 100644 packages/api/src/projects/turn.spec.ts create mode 100644 packages/api/src/projects/turn.ts diff --git a/CLAUDE.md b/CLAUDE.md index a7066c2b81b..226b6f72506 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -246,6 +246,11 @@ Multi-line imports count total character length across all lines. Consolidate va - Semantic HTML with ARIA labels (`role`, `aria-label`) for accessibility. - Group related components in feature directories (e.g., `SidePanel/Memories/`). - Use index files for clean exports. +- Use the chat Share/Export action-menu pattern: `DropdownPopup` from `@librechat/client` + with `Ariakit.MenuButton` (see `HeaderMenu.tsx` and `useExportShare.tsx`). Never introduce + or reintroduce the Radix `DropdownMenu` family for app action or sort menus. Preserve + the Share/Export dialog-item contract (`hideOnClick: false`, item ref, button render, + and dialog `triggerRef`) when a menu action opens a dialog. ### Theming and styling diff --git a/api/server/controllers/agents/__tests__/callbacks.spec.js b/api/server/controllers/agents/__tests__/callbacks.spec.js index 3082c3dad97..6423c6b4ce1 100644 --- a/api/server/controllers/agents/__tests__/callbacks.spec.js +++ b/api/server/controllers/agents/__tests__/callbacks.spec.js @@ -38,6 +38,7 @@ jest.mock('@librechat/api', () => ({ jest.mock('@librechat/data-schemas', () => ({ logger: { debug: jest.fn(), + info: jest.fn(), error: jest.fn(), }, })); diff --git a/api/server/controllers/agents/__tests__/modelEndHandler.spec.js b/api/server/controllers/agents/__tests__/modelEndHandler.spec.js index 0b4f1c8f068..e2d0880ae96 100644 --- a/api/server/controllers/agents/__tests__/modelEndHandler.spec.js +++ b/api/server/controllers/agents/__tests__/modelEndHandler.spec.js @@ -1,5 +1,5 @@ jest.mock('@librechat/data-schemas', () => ({ - logger: { error: jest.fn(), debug: jest.fn() }, + logger: { error: jest.fn(), debug: jest.fn(), info: jest.fn() }, })); jest.mock('@librechat/api', () => { const { getModelRefusalInfo } = jest.requireActual('@librechat/api'); diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index 25ae7ab6022..7ca66410c92 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -158,6 +158,7 @@ jest.mock('nanoid', () => ({ jest.mock('@librechat/data-schemas', () => ({ logger: { debug: jest.fn(), + info: jest.fn(), error: jest.fn(), warn: jest.fn(), }, @@ -179,6 +180,9 @@ jest.mock('@librechat/api', () => ({ /* Provisioning moved into this package; the controllers build the callback from it. */ createProvisionFilesCallback: () => async () => {}, createAgentExecutionContext: (context) => context, + resolveApiConversationProject: jest.fn((...args) => + jest.requireActual('@librechat/api').resolveApiConversationProject(...args), + ), /** Grants both by default; the capability set is what these specs vary. */ resolveToolRoleGrants: jest.fn(async () => ({ runCode: true, @@ -453,8 +457,8 @@ const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined); jest.mock('~/models', () => ({ getAgent: jest.fn().mockResolvedValue({ id: 'agent-123', name: 'Test Agent' }), + getProjectFiles: jest.fn(), getFiles: jest.fn(), - getUserKey: jest.fn(), getMessages: jest.fn(), updateFilesUsage: jest.fn(), getUserKeyValues: jest.fn(), @@ -1553,7 +1557,7 @@ describe('OpenAIChatCompletionController', () => { it('should return 404 when conversation is not owned by user', async () => { const { validateRequest } = require('@librechat/api'); - const { getConvo } = require('~/models'); + const { getConvo, getAgent } = require('~/models'); validateRequest.mockReturnValueOnce({ request: { model: 'agent-123', @@ -1563,11 +1567,100 @@ describe('OpenAIChatCompletionController', () => { }, }); getConvo.mockResolvedValueOnce(null); - + getAgent.mockRejectedValueOnce(new Error('speculative agent lookup failed')); await OpenAIChatCompletionController(req, res); expect(getConvo).toHaveBeenCalledWith('user-123', 'convo-abc'); expect(res.status).toHaveBeenCalledWith(404); }); + it('starts the agent read while owner-scoped conversation validation is in flight', async () => { + const api = require('@librechat/api'); + const models = require('~/models'); + models.getConvo.mockResolvedValueOnce({ conversationId: 'convo-abc', user: 'user-123' }); + let resolveAgent; + models.getAgent.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveAgent = resolve; + }), + ); + api.resolveApiConversationProject.mockImplementationOnce(async (...args) => { + expect(models.getAgent).toHaveBeenCalledWith({ id: 'agent-123' }); + resolveAgent({ id: 'agent-123', name: 'Test Agent' }); + return jest.requireActual('@librechat/api').resolveApiConversationProject(...args); + }); + api.validateRequest.mockReturnValueOnce({ + request: { + model: 'agent-123', + messages: [], + stream: false, + conversation_id: 'convo-abc', + }, + }); + + await OpenAIChatCompletionController(req, res); + + expect(models.getAgent.mock.invocationCallOrder[0]).toBeLessThan( + models.getConvo.mock.invocationCallOrder[0], + ); + expect(api.initializeAgent).toHaveBeenCalled(); + }); + + it('keeps Project-unavailable errors as client not-found responses', async () => { + const api = require('@librechat/api'); + const models = require('~/models'); + api.validateRequest.mockReturnValueOnce({ + request: { + model: 'agent-123', + messages: [], + stream: false, + conversation_id: 'convo-abc', + }, + }); + models.getAgent.mockRejectedValueOnce(new Error('speculative agent lookup failed')); + api.resolveApiConversationProject.mockResolvedValueOnce({ + ok: false, + status: 404, + reason: 'unavailable', + message: 'Conversation context unavailable', + }); + + await OpenAIChatCompletionController(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + const errorCall = api.createErrorResponse.mock.calls.at(-1); + expect(errorCall[1]).toBe('invalid_request_error'); + expect(errorCall[2]).toBeNull(); + expect(api.initializeAgent).not.toHaveBeenCalled(); + }); + + it('rechecks conversation existence after enrollment before initializing a provider', async () => { + const api = require('@librechat/api'); + const models = require('~/models'); + api.validateRequest.mockReturnValueOnce({ + request: { + model: 'agent-123', + messages: [{ role: 'user', content: 'Hello' }], + stream: false, + conversation_id: 'convo-abc', + }, + }); + let deleted = false; + models.getConvo.mockImplementation(async () => + deleted ? null : { conversationId: 'convo-abc', user: 'user-123' }, + ); + mockEnrollAgentExecution.mockImplementationOnce(async () => { + deleted = true; + return mockExecution; + }); + + await OpenAIChatCompletionController(req, res); + + expect(mockEnrollAgentExecution).toHaveBeenCalledTimes(1); + expect(api.initializeAgent).not.toHaveBeenCalled(); + expect(api.createRun).not.toHaveBeenCalled(); + expect(mockProcessStream).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(404); + }); it('should proceed when conversation is owned by user', async () => { const { validateRequest } = require('@librechat/api'); @@ -1588,7 +1681,7 @@ describe('OpenAIChatCompletionController', () => { }); it('should return 500 when getConvo throws a DB error', async () => { - const { validateRequest } = require('@librechat/api'); + const { validateRequest, createErrorResponse } = require('@librechat/api'); const { getConvo } = require('~/models'); validateRequest.mockReturnValueOnce({ request: { @@ -1602,6 +1695,7 @@ describe('OpenAIChatCompletionController', () => { await OpenAIChatCompletionController(req, res); expect(res.status).toHaveBeenCalledWith(500); + expect(createErrorResponse.mock.calls.at(-1)[1]).toBe('server_error'); }); }); diff --git a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js index 3c3e23f74d8..b725dd45cf8 100644 --- a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js +++ b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js @@ -77,6 +77,10 @@ jest.mock('@librechat/api', () => ({ jest.requireActual('@librechat/api').resolvePersistableCodeEnvironmentDecision(...args), getSafeErrorMetadata: jest.requireActual('@librechat/api').getSafeErrorMetadata, getSafeErrorText: jest.requireActual('@librechat/api').getSafeErrorText, + startAgentProjectContextResolution: + jest.requireActual('@librechat/api').startAgentProjectContextResolution, + assertChatProjectInstructions: jest.requireActual('@librechat/api').assertChatProjectInstructions, + getChatProjectTurnFailure: jest.requireActual('@librechat/api').getChatProjectTurnFailure, GenerationJobManager: mockGenerationJobManager, getReferencedQuotes: jest.fn(() => null), cleanupMCPRequestContext: jest.fn(), @@ -166,6 +170,12 @@ describe('ResumableAgentController tenant context', () => { user, jobRecord = { createdAt: 1000, contextMeta: partialContextMeta }, ) => { + mockGetConvo.mockResolvedValue({ + conversationId: 'conversation-123', + user: user.id, + tenantId: user.tenantId, + createdAt: '2026-07-31T00:00:00.000Z', + }); let allSubscribersLeftHandler; mockGenerationJobManager.getJobStore.mockReturnValue({ getJob: jest.fn().mockResolvedValue(jobRecord), diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index da907e363e7..0981b5fa629 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -67,6 +67,8 @@ const mockFilterPersistableAbortContent = jest.fn((content) => content.filter((part) => part?.type !== 'tool_call'), ); const mockGetConvo = jest.fn(); +const mockGetChatProject = jest.fn(); +const mockGetFiles = jest.fn(); const mockGetMessages = jest.fn(); const mockStampConvoLastResponse = jest.fn().mockResolvedValue(undefined); const mockSaveMessage = jest.fn(); @@ -302,6 +304,10 @@ jest.mock('@librechat/api', () => ({ getSafeErrorText: jest.requireActual('@librechat/api').getSafeErrorText, resolveFailedTurnContent: jest.requireActual('@librechat/api').resolveFailedTurnContent, getFailedTurnTraceFields: (...args) => mockGetFailedTurnTraceFields(...args), + startAgentProjectContextResolution: + jest.requireActual('@librechat/api').startAgentProjectContextResolution, + assertChatProjectInstructions: jest.requireActual('@librechat/api').assertChatProjectInstructions, + getChatProjectTurnFailure: jest.requireActual('@librechat/api').getChatProjectTurnFailure, GenerationJobManager: mockGenerationJobManager, getReferencedQuotes: jest.fn((quotes) => { if (!Array.isArray(quotes)) { @@ -392,6 +398,8 @@ jest.mock('~/models', () => ({ appendConvoMessageReference: (...args) => mockAppendConvoMessageReference(...args), getMessages: (...args) => mockGetMessages(...args), getConvo: (...args) => mockGetConvo(...args), + getChatProject: (...args) => mockGetChatProject(...args), + getProjectFiles: (...args) => mockGetFiles(...args), getAgentEventActorSnapshot: (...args) => mockGetAgentEventActorSnapshot(...args), commitAgentEventActorState: (...args) => mockCommitAgentEventActorState(...args), beginAgentEventActorLegacyTurn: (...args) => mockBeginAgentEventActorLegacyTurn(...args), @@ -463,7 +471,13 @@ describe('ResumableAgentController resume metadata', () => { mockMCPContexts = new WeakMap(); mockCheckAndIncrementPendingRequest.mockResolvedValue({ allowed: true }); mockDecrementPendingRequest.mockResolvedValue(undefined); - mockGetConvo.mockResolvedValue({ createdAt: '2026-06-07T00:00:00.000Z' }); + mockGetConvo.mockImplementation(async (user, conversationId) => ({ + user, + conversationId, + createdAt: '2026-06-07T00:00:00.000Z', + })); + mockGetChatProject.mockReset().mockResolvedValue(null); + mockGetFiles.mockReset().mockResolvedValue([]); mockGetMessages.mockResolvedValue([]); mockIsAgentTriggerPrincipalActive.mockResolvedValue(true); mockIsSubagentOwnerAdmissible.mockResolvedValue(true); @@ -1207,6 +1221,11 @@ describe('ResumableAgentController resume metadata', () => { const req = { _isAgentTrigger: true, user: { id: 'user-123', tenantId: 'tenant-1' }, + resolvedConversation: { + conversationId: 'conversation-123', + user: 'user-123', + tenantId: 'tenant-1', + }, body: { text: 'Run the queued turn.', messageId: 'queued-user-message', @@ -1239,6 +1258,11 @@ describe('ResumableAgentController resume metadata', () => { const req = { _isAgentTrigger: true, user: { id: 'user-123', tenantId: 'tenant-1' }, + resolvedConversation: { + conversationId: 'conversation-123', + user: 'user-123', + tenantId: 'tenant-1', + }, body: { text: 'Run the queued turn.', messageId: 'queued-user-message', @@ -1321,66 +1345,6 @@ describe('ResumableAgentController resume metadata', () => { ); }); - it('prefetches conversation state before admission and joins it with job metadata', async () => { - let resolveConversation; - let signalMetadataStarted; - const conversationPromise = new Promise((resolve) => { - resolveConversation = resolve; - }); - const metadataStarted = new Promise((resolve) => { - signalMetadataStarted = resolve; - }); - mockGetConvo.mockReturnValue(conversationPromise); - mockGenerationJobManager.createJob.mockImplementation(() => { - signalMetadataStarted(); - return Promise.resolve({ - createdAt: 1000, - readyPromise: Promise.resolve(), - abortController: new AbortController(), - emitter: { on: jest.fn() }, - }); - }); - const initializeClient = jest.fn().mockRejectedValue(new Error('stop after startup reads')); - const conversationId = 'conversation-123'; - const req = { - user: { id: 'user-123' }, - body: { - text: 'Run independent startup work together.', - messageId: 'user-message', - parentMessageId: 'parent-message', - conversationId, - endpointOption: { - endpoint: 'agents', - modelOptions: { model: 'gpt-4.1' }, - }, - }, - config: {}, - }; - const res = createResumableResponse(); - - const controllerPromise = AgentController(req, res, jest.fn(), initializeClient, null); - expect(mockGetConvo).toHaveBeenCalledWith('user-123', conversationId); - await metadataStarted; - await nextTick(); - - expect(mockGetConvo.mock.invocationCallOrder[0]).toBeLessThan( - mockCheckAndIncrementPendingRequest.mock.invocationCallOrder[0], - ); - expect(res.json).toHaveBeenCalledWith({ - streamId: conversationId, - conversationId, - generationCreatedAt: 1000, - status: 'started', - generationProtocolVersion: 1, - }); - expect(initializeClient).not.toHaveBeenCalled(); - - resolveConversation({ createdAt: '2026-06-07T00:00:00.000Z' }); - await controllerPromise; - - expect(initializeClient).toHaveBeenCalledTimes(1); - }); - it('keeps request-scoped MCP connections until resumable initialization finishes', async () => { const conversationId = 'conversation-123'; const disconnect = jest.fn().mockResolvedValue(undefined); @@ -3971,7 +3935,70 @@ describe('ResumableAgentController resume metadata', () => { expect(mockSaveConvo).not.toHaveBeenCalled(); }); - it('creates the conversation row for a failed first turn', async () => { + it.each([false, true])( + 'creates the conversation row for a failed first turn (authorized project: %s)', + async (hasAuthorizedProject) => { + const res = createResumableResponse(); + mockGenerationJobManager.claimGeneration.mockImplementation( + async (_userId, _clientRequestId, streamId, claimedConversationId) => + wonGenerationClaim({ streamId, conversationId: claimedConversationId }), + ); + const req = createFailedRequest({ + conversationId: undefined, + clientRequestId: 'failed-new-conversation', + agent_id: 'unverified-agent', + parentMessageId: '00000000-0000-0000-0000-000000000000', + endpointOption: { + endpoint: 'azureOpenAI', + agent_id: 'unverified-agent', + modelOptions: { model: 'gpt-4o' }, + ...(hasAuthorizedProject && { chatProjectId: '507f1f77ebcf86cd799439011' }), + }, + }); + if (hasAuthorizedProject) { + req.chatProjectContext = { + projectId: '507f1f77bcf86cd799439012', + contextRevision: 0, + instructions: '', + file_ids: [], + }; + } + + await AgentController( + req, + res, + jest.fn(), + jest.fn().mockRejectedValue(new Error('model unavailable')), + null, + ); + + const mintedConversationId = res.json.mock.calls[0][0].conversationId; + expect(mockSaveMessage).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + messageId: 'user-message_', + conversationId: mintedConversationId, + }), + expect.any(Object), + ); + expect(mockSaveConvo).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-123' }), + expect.objectContaining({ + conversationId: mintedConversationId, + endpoint: 'azureOpenAI', + model: 'gpt-4o', + }), + expect.objectContaining({ initialAgentId: null }), + ); + const savedConversation = mockSaveConvo.mock.calls[0][1]; + if (hasAuthorizedProject) { + expect(savedConversation.chatProjectId).toBe('507f1f77bcf86cd799439012'); + } else { + expect(savedConversation).not.toHaveProperty('chatProjectId'); + } + }, + ); + it('persists the normalized code-environment decision on a failed first turn', async () => { const res = createResumableResponse(); mockGenerationJobManager.claimGeneration.mockImplementation( async (_userId, _clientRequestId, streamId, claimedConversationId) => @@ -3979,14 +4006,13 @@ describe('ResumableAgentController resume metadata', () => { ); const req = createFailedRequest({ conversationId: undefined, - clientRequestId: 'failed-new-conversation', + clientRequestId: 'failed-code-environment', agent_id: 'unverified-agent', parentMessageId: '00000000-0000-0000-0000-000000000000', endpointOption: { endpoint: 'azureOpenAI', agent_id: 'unverified-agent', modelOptions: { model: 'gpt-4o' }, - chatProjectId: '507f1f77bcf86cd799439011', }, codeEnvironmentMode: 'attached', codeWorkspaces: [{ environmentId: 'personal-vm', workspaceId: 'project-a' }], @@ -4002,21 +4028,10 @@ describe('ResumableAgentController resume metadata', () => { await AgentController(req, res, jest.fn(), initializeClient, null); const mintedConversationId = res.json.mock.calls[0][0].conversationId; - expect(mockSaveMessage).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ - messageId: 'user-message_', - conversationId: mintedConversationId, - }), - expect.any(Object), - ); expect(mockSaveConvo).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user-123' }), + expect.any(Object), expect.objectContaining({ conversationId: mintedConversationId, - endpoint: 'azureOpenAI', - model: 'gpt-4o', - chatProjectId: '507f1f77bcf86cd799439011', codeEnvironmentMode: 'attached', codeWorkspaces: [{ environmentId: 'personal-vm', workspaceId: 'project-a' }], }), @@ -4086,6 +4101,128 @@ describe('ResumableAgentController resume metadata', () => { ); }); }); + describe('agent project preflight', () => { + beforeEach(() => { + mockGenerationJobManager.claimGeneration.mockImplementation( + async (_userId, _clientRequestId, streamId, conversationId) => + wonGenerationClaim({ streamId, conversationId }), + ); + }); + + const createProjectRequest = ({ conversationId, clientRequestId = 'project-preflight' }) => ({ + user: { id: 'user-123', tenantId: 'tenant-1' }, + body: { + text: 'Use the selected project.', + messageId: 'project-user-message', + clientRequestId, + ...(conversationId !== undefined && { conversationId }), + endpointOption: { + endpoint: 'agents', + modelOptions: { model: 'gpt-4o' }, + chatProjectId: 'project-1', + }, + }, + config: {}, + }); + + it('rejects a new conversation whose requested project is missing before creating a job', async () => { + mockGetConvo.mockResolvedValue({ user: 'user-123', tenantId: 'tenant-1' }); + mockGetChatProject.mockResolvedValue(null); + const initializeClient = jest.fn(); + const res = createResumableResponse(); + + await AgentController(createProjectRequest({}), res, jest.fn(), initializeClient, null); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'Conversation context unavailable' }), + ); + expect(mockGenerationJobManager.createJob).not.toHaveBeenCalled(); + expect(initializeClient).not.toHaveBeenCalled(); + + expect(mockSaveMessage).not.toHaveBeenCalled(); + expect(mockSaveConvo).not.toHaveBeenCalled(); + expect(mockGenerationJobManager.releaseGeneration).toHaveBeenCalled(); + expect(mockDecrementPendingRequest).toHaveBeenCalledWith('user-123'); + }); + + it.each([ + ['missing', null], + [ + 'foreign-tenant', + { _id: 'project-1', tenantId: 'tenant-2', instructions: '', file_ids: [] }, + ], + ])( + 'continues an existing conversation without context when its stored project is %s', + async (_label, storedProject) => { + mockGetConvo.mockResolvedValue({ + conversationId: 'conversation-123', + chatProjectId: 'project-1', + user: 'user-123', + tenantId: 'tenant-1', + }); + mockGetChatProject.mockResolvedValue(storedProject); + const req = createProjectRequest({ + conversationId: 'conversation-123', + clientRequestId: `project-${_label}`, + }); + const res = createResumableResponse(); + + await AgentController(req, res, jest.fn(), jest.fn(), null); + + expect(res.status).not.toHaveBeenCalledWith(404); + expect(req.chatProjectContext).toBeNull(); + expect(mockGenerationJobManager.createJob).toHaveBeenCalled(); + }, + ); + + it('rejects project instructions by policy before admission reaches a provider', async () => { + mockGetConvo.mockResolvedValue({ + conversationId: 'conversation-123', + chatProjectId: 'project-1', + user: 'user-123', + tenantId: 'tenant-1', + }); + mockGetChatProject.mockResolvedValue({ + _id: 'project-1', + tenantId: 'tenant-1', + instructions: 'Use sk_project_secret in every answer.', + contextRevision: 1, + file_ids: [], + }); + mockGetFiles.mockResolvedValue([]); + const req = createProjectRequest({ conversationId: 'conversation-123' }); + req.config.filters = { + agentInstructions: { + pii: { + fields: ['instructions'], + starterPatterns: [], + customPatterns: [ + { id: 'project-secret', label: 'Project secret', regex: 'sk_project_secret' }, + ], + }, + }, + }; + const initializeClient = jest.fn(); + const res = createResumableResponse(); + + await AgentController(req, res, jest.fn(), initializeClient, null); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'content_filter_block', + source: 'agent_instruction', + field: 'instructions', + }), + ); + expect(JSON.stringify(res.json.mock.calls)).not.toContain('sk_project_secret'); + expect(mockGenerationJobManager.createJob).not.toHaveBeenCalled(); + expect(initializeClient).not.toHaveBeenCalled(); + expect(mockSaveMessage).not.toHaveBeenCalled(); + expect(mockSaveConvo).not.toHaveBeenCalled(); + }); + }); it.each([ new (jest.requireActual('@librechat/api').OpenIDReauthRequiredError)( @@ -4702,16 +4839,29 @@ describe('ResumableAgentController resume metadata', () => { ); }); - it.each(['submitted', 'persisted', 'overridden'])( + it.each(['submitted', 'persisted', 'overridden', 'loaded'])( 'preserves %s workspace selections in the runtime envelope', async (source) => { mockGenerationJobManager.claimGeneration.mockResolvedValue(wonGenerationClaim()); const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading')); const codeWorkspaces = [{ environmentId: 'machine-a', workspaceId: 'project-b' }]; + const persistedConversation = { + conversationId: 'conversation-123', + user: 'user-123', + codeWorkspaces, + }; + let resolveStoredConversation; + if (source === 'loaded') { + mockGetConvo.mockReturnValueOnce( + new Promise((resolve) => { + resolveStoredConversation = resolve; + }), + ); + } const req = { user: { id: 'user-123' }, - ...(source !== 'submitted' - ? { resolvedConversation: { conversationId: 'conversation-123', codeWorkspaces } } + ...(source === 'persisted' || source === 'overridden' + ? { resolvedConversation: persistedConversation } : {}), body: { ...(source === 'submitted' ? { codeWorkspaces } : {}), @@ -4733,7 +4883,12 @@ describe('ResumableAgentController resume metadata', () => { set: jest.fn(), }; - await AgentController(req, res, jest.fn(), initializeClient, null); + const controllerPromise = AgentController(req, res, jest.fn(), initializeClient, null); + if (source === 'loaded') { + await nextTick(); + resolveStoredConversation(persistedConversation); + } + await controllerPromise; expect(initializeClient).toHaveBeenCalledWith( expect.objectContaining({ @@ -5913,6 +6068,11 @@ describe('ResumableAgentController resume metadata', () => { mockAcquireEventChildGenerationLease.mockResolvedValue(null); const req = { user: { id: 'user-123', tenantId: 'tenant-1' }, + resolvedConversation: { + conversationId: 'child-conversation', + user: 'user-123', + tenantId: 'tenant-1', + }, body: { text: 'This event arrived too late.', messageId: 'user-msg', diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index e8b1f2e7abe..15bcdfa0b6f 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -181,6 +181,7 @@ jest.mock('uuid', () => ({ jest.mock('@librechat/data-schemas', () => ({ logger: { debug: jest.fn(), + info: jest.fn(), error: jest.fn(), warn: jest.fn(), }, @@ -202,6 +203,9 @@ jest.mock('@librechat/api', () => ({ /* Provisioning moved into this package; the controllers build the callback from it. */ createProvisionFilesCallback: () => async () => {}, createAgentExecutionContext: (context) => context, + resolveApiConversationProject: jest.fn((...args) => + jest.requireActual('@librechat/api').resolveApiConversationProject(...args), + ), /** Grants both by default; the capability set is what these specs vary. */ resolveToolRoleGrants: jest.fn(async () => ({ runCode: true, @@ -540,8 +544,8 @@ const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined); jest.mock('~/models', () => ({ getAgent: jest.fn().mockResolvedValue({ id: 'agent-123', name: 'Test Agent' }), + getProjectFiles: jest.fn(), getFiles: jest.fn(), - getUserKey: jest.fn(), getMessages: jest.fn().mockResolvedValue([]), saveMessage: jest.fn().mockResolvedValue({}), updateFilesUsage: jest.fn(), @@ -2069,7 +2073,7 @@ describe('createResponse controller', () => { it('should return 404 when conversation is not owned by user', async () => { const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api'); - const { getConvo } = require('~/models'); + const { getConvo, getAgent } = require('~/models'); validateResponseRequest.mockReturnValueOnce({ request: { model: 'agent-123', @@ -2079,6 +2083,7 @@ describe('createResponse controller', () => { }, }); getConvo.mockResolvedValueOnce(null); + getAgent.mockRejectedValueOnce(new Error('speculative agent lookup failed')); await createResponse(req, res); expect(getConvo).toHaveBeenCalledWith('user-123', 'resp_abc'); @@ -2089,6 +2094,95 @@ describe('createResponse controller', () => { 'not_found', ); }); + it('starts the agent read while owner-scoped conversation validation is in flight', async () => { + const api = require('@librechat/api'); + const models = require('~/models'); + models.getConvo.mockResolvedValueOnce({ conversationId: 'resp_abc', user: 'user-123' }); + let resolveAgent; + models.getAgent.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveAgent = resolve; + }), + ); + api.resolveApiConversationProject.mockImplementationOnce(async (...args) => { + expect(models.getAgent).toHaveBeenCalledWith({ id: 'agent-123' }); + resolveAgent({ id: 'agent-123', name: 'Test Agent' }); + return jest.requireActual('@librechat/api').resolveApiConversationProject(...args); + }); + api.validateResponseRequest.mockReturnValueOnce({ + request: { + model: 'agent-123', + input: 'Hello', + stream: false, + previous_response_id: 'resp_abc', + }, + }); + + await createResponse(req, res); + + expect(models.getAgent.mock.invocationCallOrder[0]).toBeLessThan( + models.getConvo.mock.invocationCallOrder[0], + ); + expect(api.initializeAgent).toHaveBeenCalled(); + }); + + it('keeps Project-unavailable errors as Responses not-found responses', async () => { + const api = require('@librechat/api'); + const models = require('~/models'); + api.validateResponseRequest.mockReturnValueOnce({ + request: { + model: 'agent-123', + input: 'Hello', + stream: false, + previous_response_id: 'resp_abc', + }, + }); + models.getAgent.mockRejectedValueOnce(new Error('speculative agent lookup failed')); + api.resolveApiConversationProject.mockResolvedValueOnce({ + ok: false, + status: 404, + reason: 'unavailable', + message: 'Conversation context unavailable', + }); + + await createResponse(req, res); + + const errorCall = api.sendResponsesErrorResponse.mock.calls.at(-1); + expect(errorCall[1]).toBe(404); + expect(errorCall[3]).toBe('not_found'); + expect(api.initializeAgent).not.toHaveBeenCalled(); + }); + + it('rechecks conversation existence after enrollment before provider or persistence work', async () => { + const api = require('@librechat/api'); + const models = require('~/models'); + api.validateResponseRequest.mockReturnValueOnce({ + request: { + model: 'agent-123', + input: 'Hello', + stream: false, + store: true, + previous_response_id: 'resp_abc', + }, + }); + let deleted = false; + models.getConvo.mockImplementation(async () => + deleted ? null : { conversationId: 'resp_abc', user: 'user-123' }, + ); + mockEnrollAgentExecution.mockImplementationOnce(async () => { + deleted = true; + return mockExecution; + }); + + await createResponse(req, res); + + expect(mockEnrollAgentExecution).toHaveBeenCalledTimes(1); + expect(api.initializeAgent).not.toHaveBeenCalled(); + expect(api.createRun).not.toHaveBeenCalled(); + expect(models.saveConvo).not.toHaveBeenCalled(); + expect(models.saveMessage).not.toHaveBeenCalled(); + }); it('should proceed when conversation is owned by user', async () => { const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api'); @@ -2168,6 +2262,7 @@ describe('createResponse controller', () => { expect.any(String), expect.any(String), ); + expect(sendResponsesErrorResponse.mock.calls.at(-1)[3]).toBe('server_error'); }); }); diff --git a/api/server/controllers/agents/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js index a01d9802ebf..cf0ebef3f14 100644 --- a/api/server/controllers/agents/__tests__/resume.spec.js +++ b/api/server/controllers/agents/__tests__/resume.spec.js @@ -84,8 +84,10 @@ const mockCheckpointGetTuple = jest.fn(); const mockSaveMessage = jest.fn(); const mockGetConvo = jest.fn(); +const mockGetChatProject = jest.fn(); const mockGetMessages = jest.fn(); const mockGetFiles = jest.fn(); +const mockGetProjectFiles = jest.fn(); const mockGetAgent = jest.fn(); const mockGetActions = jest.fn(); const mockGetUserMemories = jest.fn(); @@ -151,8 +153,10 @@ jest.mock('@librechat/api', () => ({ jest.mock('~/models', () => ({ saveMessage: (...args) => mockSaveMessage(...args), getConvo: (...args) => mockGetConvo(...args), + getChatProject: (...args) => mockGetChatProject(...args), getMessages: (...args) => mockGetMessages(...args), getFiles: (...args) => mockGetFiles(...args), + getProjectFiles: (...args) => mockGetProjectFiles(...args), getAgent: (...args) => mockGetAgent(...args), getActions: (...args) => mockGetActions(...args), getUserMemories: (...args) => mockGetUserMemories(...args), @@ -329,8 +333,10 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => { mockCleanupMCPRequestContextForReq.mockResolvedValue(undefined); mockSaveMessage.mockResolvedValue({}); mockGetConvo.mockResolvedValue(null); + mockGetChatProject.mockResolvedValue(null); mockGetMessages.mockResolvedValue([]); mockGetFiles.mockResolvedValue([]); + mockGetProjectFiles.mockResolvedValue([]); mockGetAgent.mockResolvedValue(null); mockGetActions.mockResolvedValue([]); mockGetUserMemories.mockResolvedValue([]); @@ -2232,6 +2238,200 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => { }); }); + describe('project context resume fencing', () => { + const projectConversation = { + conversationId: CONVO_ID, + chatProjectId: 'project-1', + user: USER_ID, + tenantId: TENANT_ID, + }; + const projectResource = { + _id: '507f1f77bcf86cd799439012', + file_id: 'file-1', + filename: 'reference.txt', + filepath: '/uploads/reference.txt', + object: 'file', + type: 'text/plain', + bytes: 10, + usage: 0, + embedded: true, + context: 'message_attachment', + user: USER_ID, + tenantId: TENANT_ID, + updatedAt: new Date('2020-01-01'), + }; + + const projectRecord = (contextRevision, instructions = 'Use the project context.') => ({ + _id: 'project-1', + tenantId: TENANT_ID, + contextRevision, + instructions, + file_ids: ['file-1'], + }); + + const withProject = (contextRevision, pendingRevision) => { + const { getChatProjectContextKey, toCanonicalProjectResource } = + jest.requireActual('@librechat/api'); + const pendingKey = + pendingRevision === undefined + ? undefined + : getChatProjectContextKey({ + ...projectRecord(pendingRevision), + projectId: 'project-1', + resources: [toCanonicalProjectResource(projectResource)], + }); + mockGetConvo.mockResolvedValue(projectConversation); + mockGetChatProject.mockResolvedValue(projectRecord(contextRevision)); + mockGetProjectFiles.mockResolvedValue([projectResource]); + mockGenerationJobManager.getJob.mockResolvedValue( + makeToolApprovalJob({ + metadata: { pendingAction: { projectContextKey: pendingKey } }, + }), + ); + }; + + it('resumes when the authoritative project context key is unchanged', async () => { + withProject(3, 3); + const res = await post(approveBody()); + await settled; + expect(res.status).toBe(200); + expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalled(); + expect(mockInitializeClient).toHaveBeenCalled(); + }); + + it.each([ + ['deleted', []], + ['expired', [{ ...projectResource, expiredAt: new Date(0) }]], + ['ineligible', [{ ...projectResource, embedded: false }]], + ['replaced', [{ ...projectResource, _id: '507f1f77bcf86cd799439013' }]], + ['changed content', [{ ...projectResource, updatedAt: new Date('2030-01-01') }]], + ])('rejects a %s canonical file without a Project revision change', async (_change, files) => { + withProject(3, 3); + mockGetProjectFiles.mockResolvedValue(files); + const res = await post(approveBody()); + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ code: 'PROJECT_CONTEXT_CHANGED' }); + expect(mockInitializeClient).not.toHaveBeenCalled(); + expect(mockDeleteAgentCheckpoint).toHaveBeenCalledWith( + CONVO_ID, + { type: 'mongo' }, + { threadId: CONVO_ID, checkpointIds: ['checkpoint-old'] }, + ); + }); + + it('ignores usage and temporary-hold bookkeeping when resuming the same content', async () => { + withProject(3, 3); + mockGetProjectFiles.mockResolvedValue([ + { + ...projectResource, + usage: 9, + temp_file_id: 'temporary-id', + expiresAt: new Date('2030-01-01'), + }, + ]); + const res = await post(approveBody()); + await settled; + expect(res.status).toBe(200); + expect(mockInitializeClient).toHaveBeenCalled(); + }); + + it('rejects a resume whose Project no longer exists', async () => { + withProject(3, 3); + mockGetChatProject.mockResolvedValue(null); + const res = await post(approveBody()); + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ code: 'PROJECT_CONTEXT_CHANGED' }); + expect(mockInitializeClient).not.toHaveBeenCalled(); + }); + + it('terminalizes and prunes the claimed epoch when project context changes', async () => { + withProject(4, 3); + const res = await post(approveBody()); + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ code: 'PROJECT_CONTEXT_CHANGED' }); + expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalled(); + expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith( + CONVO_ID, + expect.any(String), + 1000, + ); + expect(mockGenerationJobManager.approvals.resolve.mock.invocationCallOrder[0]).toBeLessThan( + mockGenerationJobManager.completeJob.mock.invocationCallOrder[0], + ); + expect(mockDeleteAgentCheckpoint).toHaveBeenCalledWith( + CONVO_ID, + { type: 'mongo' }, + { threadId: CONVO_ID, checkpointIds: ['checkpoint-old'] }, + ); + expect(mockInitializeClient).not.toHaveBeenCalled(); + }); + it('terminalizes a claimed resume when the fresh project lookup fails', async () => { + withProject(3, 3); + mockGetConvo.mockRejectedValueOnce(new Error('fresh project read failed')); + + const res = await post(approveBody()); + await settled; + + expect(res.status).toBe(500); + expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalled(); + expect(mockGetConvo.mock.invocationCallOrder[0]).toBeGreaterThan( + mockGenerationJobManager.approvals.resolve.mock.invocationCallOrder[0], + ); + expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith( + CONVO_ID, + expect.any(String), + 1000, + ); + expect(mockDeleteAgentCheckpoint).toHaveBeenCalledWith( + CONVO_ID, + { type: 'mongo' }, + { threadId: CONVO_ID, checkpointIds: ['checkpoint-old'] }, + ); + expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID); + expect(mockInitializeClient).not.toHaveBeenCalled(); + expect(mockGenerationJobManager.beginProviderExecution).not.toHaveBeenCalled(); + }); + + it('does not answer PROJECT_CONTEXT_CHANGED when terminalization itself fails', async () => { + withProject(4, 3); + mockGenerationJobManager.completeJob.mockRejectedValueOnce(new Error('redis unavailable')); + + const res = await post(approveBody()); + await settled; + + /* The approval CAS already moved this generation back to running, so a swallowed + * storage failure would leave it running with no provider owner behind a + * non-retryable 409. The failure has to reach the resume failure path instead. */ + expect(res.status).toBe(500); + expect(res.body).not.toMatchObject({ code: 'PROJECT_CONTEXT_CHANGED' }); + expect(mockGenerationJobManager.completeJob).toHaveBeenCalledTimes(2); + expect(mockInitializeClient).not.toHaveBeenCalled(); + expect(mockGenerationJobManager.beginProviderExecution).not.toHaveBeenCalled(); + }); + + it('resumes a legacy pause without a recorded key under the current project context', async () => { + withProject(3, undefined); + const res = await post(approveBody()); + await settled; + expect(res.status).toBe(200); + expect(mockGenerationJobManager.completeJob).not.toHaveBeenCalledWith( + CONVO_ID, + 'Project context changed before approval could be resumed', + expect.anything(), + ); + expect(mockInitializeClient).toHaveBeenCalled(); + }); + + it('does not terminalize or prune when the approval claim loses its CAS', async () => { + withProject(4, 3); + mockGenerationJobManager.approvals.resolve.mockResolvedValue(false); + const res = await post(approveBody()); + expect(res.status).toBe(409); + expect(mockGenerationJobManager.completeJob).not.toHaveBeenCalled(); + expect(mockDeleteAgentCheckpoint).not.toHaveBeenCalled(); + }); + }); + describe('request guards (rejected before claiming the action)', () => { it('400 when conversationId is missing', async () => { const res = await post({ actionId: ACTION_ID }); diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 16fe1ee9b30..960e7269ab0 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -184,6 +184,7 @@ const { isAgentRunCancellation, markCompactionOutcome, resolvePersistableCodeEnvironmentDecision, + getChatProjectContextKey, } = require('@librechat/api'); const { Run, @@ -1955,7 +1956,7 @@ class AgentClient extends BaseClient { { spec: this.options.spec, iconURL: this.options.iconURL, - chatProjectId: this.options.chatProjectId, + chatProjectId: this.options.req?.chatProjectContext?.projectId, endpoint: this.options.endpoint, agent_id: this.options.agent.id, modelLabel: this.options.modelLabel, @@ -2208,6 +2209,7 @@ class AgentClient extends BaseClient { memory, discoveredToolNames, checkpointerType: agentsConfig?.checkpointer?.type, + projectContextKey: getChatProjectContextKey(this.options.req?.chatProjectContext), }), skillManifest, discoveredToolNames, @@ -3322,8 +3324,10 @@ class AgentClient extends BaseClient { }, codeEnvAvailable: memoryCodeEnabled && memoryToolGrants?.runCode === true, statefulSessionsAvailable: memoryCapabilities.has(AgentCapabilities.stateful_code_sessions), + useChatProjectContext: false, }, { + getProjectFiles: db.getProjectFiles, getFiles: db.getFiles, getUserKey: db.getUserKey, getConvoFiles: db.getConvoFiles, @@ -4339,6 +4343,9 @@ class AgentClient extends BaseClient { // fall back to it when the SDK doesn't echo threadId on the interrupt. threadId: interrupt.threadId ?? this.conversationId, ttlMs: getApprovalTtlMs(checkpointerCfg), + // Bind the pause to the authoritative project identity/revision. The key is + // server-only and is checked before provider/tool startup on resume. + projectContextKey: getChatProjectContextKey(this.options.req?.chatProjectContext), expiresAt: this.options.req?._agentEventBindingRetention?.expiredAt, // Pin the graph-determining request fields so resume can't rebuild this paused // run on a different agent/tool set (esp. ephemeral agents, whose agent_id is diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 6cb1e800531..28f996d0d2b 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -72,6 +72,7 @@ const { executeAgentRun, waitForAgentExecutionWrites, resolveToolRoleGrants, + resolveApiConversationProject, resolveAdmittedCodeEnvironmentDecision, createTerminalRunErrorObserver, } = require('@librechat/api'); @@ -284,16 +285,9 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { // Request-backed tool adapters still observe the validated envelope payload; // shared initialization receives the transport-free runtime below. req.body = request; - req.turnStartedAt = envelope.receivedAt; - const agentRuntime = createAgentExecutionContext({ - user: req.user, - appConfig, - requestBody: request, - turnStartedAt: envelope.receivedAt, - conversationCreatedAt: req.conversationCreatedAt, - resolvedConversation: req.resolvedConversation, - hasResolvedConversation: Object.prototype.hasOwnProperty.call(req, 'resolvedConversation'), - }); + if (request.conversation_id != null && typeof request.conversation_id !== 'string') { + return sendErrorResponse(res, 400, 'conversation_id must be a string', 'invalid_request_error'); + } const agentId = request.model; const manualSkills = extractManualSkills(req.body); @@ -369,18 +363,6 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { ); } - // Look up the agent - const agent = await db.getAgent({ id: agentId }); - if (!agent) { - return sendErrorResponse( - res, - 404, - `Agent not found: ${agentId}`, - 'invalid_request_error', - 'model_not_found', - ); - } - const responseId = `chatcmpl-${nanoid()}`; const terminalRunError = createTerminalRunErrorObserver({ maxProviderErrorChars: appConfig?.endpoints?.agents?.maxProviderErrorChars, @@ -449,22 +431,49 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { return handleExecutionError({ error, res, context, appConfig }); }, execute: async (execution) => { + const agentPromise = db.getAgent({ id: agentId }); + // Validation may return before this promise is awaited; preserve the original + // promise for the later await while avoiding an unhandled speculative rejection. + agentPromise.catch(() => {}); if (request.conversation_id != null) { - if (typeof request.conversation_id !== 'string') { + const project = await resolveApiConversationProject( + { + userId: principal.userId, + tenantId: principal.tenantId, + conversationId: request.conversation_id, + }, + { + getConvo: db.getConvo, + getChatProject: db.getChatProject, + getProjectFiles: db.getProjectFiles, + logger, + logPrefix: '[OpenAI API]', + }, + ); + if (!project.ok) { return sendErrorResponse( res, - 400, - 'conversation_id must be a string', - 'invalid_request_error', + project.status, + project.message, + project.reason === 'server_error' ? 'server_error' : 'invalid_request_error', ); } - const conversation = await db.getConvo(principal.userId, request.conversation_id); - if (!conversation) { - return sendErrorResponse(res, 404, 'Conversation not found', 'invalid_request_error'); - } - req.resolvedConversation = conversation; + req.resolvedConversation = project.conversation; + req.chatProjectContext = project.context; + } + + const agent = await agentPromise; + if (!agent) { + return sendErrorResponse( + res, + 404, + `Agent not found: ${agentId}`, + 'invalid_request_error', + 'model_not_found', + ); } + req.turnStartedAt = envelope.receivedAt; const { decision: codeEnvironmentDecision, conversation: admittedConversation } = await resolveAdmittedCodeEnvironmentDecision({ appConfig, @@ -475,6 +484,17 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { readDecision: (id) => db.readAdmittedConvoCodeEnvironmentDecision(principal.userId, id), }); req.resolvedConversation = admittedConversation; + const agentRuntime = createAgentExecutionContext({ + user: req.user, + appConfig, + requestBody: request, + turnStartedAt: envelope.receivedAt, + conversationCreatedAt: req.conversationCreatedAt, + resolvedConversation: req.resolvedConversation, + hasResolvedConversation: Object.prototype.hasOwnProperty.call(req, 'resolvedConversation'), + chatProjectContext: req.chatProjectContext, + }); + const parentMessageId = request.parent_message_id ?? null; let mcpParentMessageId; if ( @@ -511,6 +531,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { const skillDbMethods = getSkillDbMethods(); const dbMethods = { + getProjectFiles: db.getProjectFiles, getConvoFiles: db.getConvoFiles, getFiles: db.getFiles, filterFilesByAgentAccess: filterFilesByRemoteAgentAccess, @@ -620,6 +641,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { endpointOption, allowedProviders, isInitialAgent: true, + useChatProjectContext: true, accessibleSkillIds: primaryScopedSkillIds, skillAuthoringAvailable: canAuthorSkillFiles({ agent, @@ -688,6 +710,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { conversationId, parentMessageId, requestBody: mcpRequestBody, + useChatProjectContext: true, resourceType: ResourceType.REMOTE_AGENT, computeAccessibleSkillIds: (handoffAgent) => resolveAgentScopedSkillIds({ @@ -1102,11 +1125,11 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { appConfig, signal: execution.signal, customHandlers: handlers, + modelCallbacks: [terminalRunError.modelCallback], requestBody: mcpRequestBody, user: { ...createSafeUser(req.user), id: userId }, traceContext: { endpoint: EModelEndpoint.agents }, tenantId: principal.tenantId, - modelCallbacks: [terminalRunError.modelCallback], /** Bills subagent child-run model calls (reported outside the * streamEvents loop) into the same collectedUsage array. */ subagentUsageSink: createSubagentUsageSink(collectedUsage), diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index eca096527c0..fa656d53ae3 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -24,6 +24,9 @@ const { exemptFromConcurrencyLimiter, isScheduleFireRequest, isUnpersistedPreliminaryParent, + startAgentProjectContextResolution, + assertChatProjectInstructions, + getChatProjectTurnFailure, resolveConversationAnchor, getAgentStartupTelemetry, acceptAgentStartupTelemetry, @@ -68,6 +71,8 @@ const { saveConvo, getMessages, getConvo, + getChatProject, + getProjectFiles, getAgentEventActorSnapshot, commitAgentEventActorState, storeAgentEventActorSuspension, @@ -119,6 +124,11 @@ function getInitializationFailure(error) { }; } + const projectFailure = getChatProjectTurnFailure(error); + if (projectFailure) { + return projectFailure; + } + const metadata = getAgentErrorMetadata(error); if (!metadata?.status) { return null; @@ -521,7 +531,7 @@ async function saveErrorTurn( } const agentId = endpointOption?.agent_id ?? req.body?.agent_id; - const chatProjectId = endpointOption?.chatProjectId ?? req.body?.chatProjectId; + const chatProjectId = req.chatProjectContext?.projectId; const seedConvo = isNewConvo || req.resolvedConversation === null; /** A stored turn seals the decision it ran under, on a saved chat as much as on a new one: the * error turn below enters the conversation, so leaving its validated decision out would let a @@ -919,6 +929,19 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit ? req.resolvedConversation : undefined, }); + // Resolve the authoritative project context while idempotency and admission + // gates proceed below. The promise is awaited before createJob, so rejected + // project policy never receives an HTTP generation ACK. + const chatProjectContextPromise = startAgentProjectContextResolution({ + req, + endpointOption, + conversationId, + isNewConvo, + conversationAnchorPromise, + getConvo, + getChatProject, + getProjectFiles, + }); /** A newly bound actor conversation has no child messages yet, so its first * event legitimately uses the root parent id. The authenticated write guard @@ -1539,18 +1562,6 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit ) { preallocatedResponseMessageId = crypto.randomUUID(); } - const mcpRequestBody = createMCPRuntimeRequestBody({ - messageId: preallocatedResponseMessageId, - conversationId: effectiveConversationId, - codeEnvironmentMode: req.body.codeEnvironmentMode, - codeWorkspaces: resolveRunCodeWorkspaces({ - conversationId: effectiveConversationId, - requestedSelections: req.body.codeWorkspaces, - conversation: req.resolvedConversation, - }), - parentMessageId: - editedContent != null ? preallocatedResponseMessageId : preallocatedUserMessageId, - }); let client = null; let verifiedInitialAgentId = null; @@ -1607,6 +1618,22 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit req._agentEventTriggerProjection = getAgentEventTriggerProjection(agentEventDelivery); try { + assertChatProjectInstructions({ + context: await chatProjectContextPromise, + filters: req.config?.filters, + }); + const mcpRequestBody = createMCPRuntimeRequestBody({ + messageId: preallocatedResponseMessageId, + conversationId: effectiveConversationId, + codeEnvironmentMode: req.body.codeEnvironmentMode, + codeWorkspaces: resolveRunCodeWorkspaces({ + conversationId: effectiveConversationId, + requestedSelections: req.body.codeWorkspaces, + conversation: req.resolvedConversation, + }), + parentMessageId: + editedContent != null ? preallocatedResponseMessageId : preallocatedUserMessageId, + }); logger.debug(`[ResumableAgentController] Creating job`, { streamId, conversationId, diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 016ee1d10ad..13917a58a11 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -85,6 +85,7 @@ const { executeAgentRun, waitForAgentExecutionWrites, resolveToolRoleGrants, + resolveApiConversationProject, resolveAdmittedCodeEnvironmentDecision, resolvePersistableCodeEnvironmentDecision, createTerminalRunErrorObserver, @@ -524,16 +525,14 @@ const executeResponse = async (envelope, { req, res }) => { // Request-backed tool adapters still observe the validated envelope payload; // shared initialization receives the transport-free runtime below. req.body = request; - req.turnStartedAt = envelope.receivedAt; - const agentRuntime = createAgentExecutionContext({ - user: req.user, - appConfig, - requestBody: request, - turnStartedAt: envelope.receivedAt, - conversationCreatedAt: req.conversationCreatedAt, - resolvedConversation: req.resolvedConversation, - hasResolvedConversation: Object.prototype.hasOwnProperty.call(req, 'resolvedConversation'), - }); + if (request.previous_response_id != null && typeof request.previous_response_id !== 'string') { + return sendResponsesErrorResponse( + res, + 400, + 'previous_response_id must be a string', + 'invalid_request', + ); + } const agentId = request.model; const manualSkills = extractManualSkills(req.body); const isStreaming = request.stream === true; @@ -615,18 +614,6 @@ const executeResponse = async (envelope, { req, res }) => { ); } - // Look up the agent - const agent = await db.getAgent({ id: agentId }); - if (!agent) { - return sendResponsesErrorResponse( - res, - 404, - `Agent not found: ${agentId}`, - 'not_found', - 'model_not_found', - ); - } - // Generate IDs const responseId = generateResponseId(); const terminalRunError = createTerminalRunErrorObserver({ @@ -689,24 +676,27 @@ const executeResponse = async (envelope, { req, res }) => { return handleExecutionError({ error, res, appConfig }); }, execute: async (execution) => { + const agentPromise = db.getAgent({ id: agentId }); + // Validation may return before this promise is awaited; preserve the original + // promise for the later await while avoiding an unhandled speculative rejection. + agentPromise.catch(() => {}); if (request.previous_response_id != null) { - if (typeof request.previous_response_id !== 'string') { - return sendResponsesErrorResponse( - res, - 400, - 'previous_response_id must be a string', - 'invalid_request', - ); - } - const previousConversation = await db.getConvo( - principal.userId, - request.previous_response_id, + const project = await resolveApiConversationProject( + { + userId: principal.userId, + tenantId: principal.tenantId, + conversationId: request.previous_response_id, + rejectSubagentThread: true, + }, + { + getConvo: db.getConvo, + getChatProject: db.getChatProject, + getProjectFiles: db.getProjectFiles, + logger, + logPrefix: '[Responses API]', + }, ); - if (!previousConversation) { - return sendResponsesErrorResponse(res, 404, 'Conversation not found', 'not_found'); - } - req.resolvedConversation = previousConversation; - if (previousConversation.subagentThread != null) { + if (!project.ok && project.reason === 'read_only') { return sendResponsesErrorResponse( res, 409, @@ -715,8 +705,30 @@ const executeResponse = async (envelope, { req, res }) => { 'conversation_read_only', ); } + if (!project.ok) { + return sendResponsesErrorResponse( + res, + project.status, + project.message, + project.reason === 'server_error' ? 'server_error' : 'not_found', + ); + } + req.resolvedConversation = project.conversation; + req.chatProjectContext = project.context; + } + + const agent = await agentPromise; + if (!agent) { + return sendResponsesErrorResponse( + res, + 404, + `Agent not found: ${agentId}`, + 'not_found', + 'model_not_found', + ); } + req.turnStartedAt = envelope.receivedAt; const { decision: codeEnvironmentDecision, conversation: admittedConversation } = await resolveAdmittedCodeEnvironmentDecision({ appConfig, @@ -727,6 +739,17 @@ const executeResponse = async (envelope, { req, res }) => { readDecision: (id) => db.readAdmittedConvoCodeEnvironmentDecision(principal.userId, id), }); req.resolvedConversation = admittedConversation; + const agentRuntime = createAgentExecutionContext({ + user: req.user, + appConfig, + requestBody: request, + turnStartedAt: envelope.receivedAt, + conversationCreatedAt: req.conversationCreatedAt, + resolvedConversation: req.resolvedConversation, + hasResolvedConversation: Object.prototype.hasOwnProperty.call(req, 'resolvedConversation'), + chatProjectContext: req.chatProjectContext, + }); + const parentMessageId = null; const mcpRequestBody = createMCPRuntimeRequestBody({ messageId: responseId, @@ -765,6 +788,7 @@ const executeResponse = async (envelope, { req, res }) => { }; const dbMethods = { + getProjectFiles: db.getProjectFiles, getConvoFiles: db.getConvoFiles, getFiles: db.getFiles, filterFilesByAgentAccess: filterFilesByRemoteAgentAccess, @@ -874,6 +898,7 @@ const executeResponse = async (envelope, { req, res }) => { endpointOption, allowedProviders, isInitialAgent: true, + useChatProjectContext: true, accessibleSkillIds: primaryScopedSkillIds, skillAuthoringAvailable: canAuthorSkillFiles({ agent, @@ -941,6 +966,7 @@ const executeResponse = async (envelope, { req, res }) => { requestFiles: [], conversationId, parentMessageId, + useChatProjectContext: true, requestBody: mcpRequestBody, resourceType: ResourceType.REMOTE_AGENT, computeAccessibleSkillIds: (handoffAgent) => diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js index f3f151e24bf..3cffe9b151b 100644 --- a/api/server/controllers/agents/resume.js +++ b/api/server/controllers/agents/resume.js @@ -47,6 +47,9 @@ const { findAgentEventAppliedAction, assertCodeExecutionApprovalBinding, collectReachableAgents, + rejectChangedResumeProjectContext, + PROJECT_CONTEXT_CHANGED_REASON, + PROJECT_CONTEXT_CHANGED_RESPONSE, restoreScheduledTokenContext, recoverTurnMessageReference, announceReply, @@ -61,7 +64,9 @@ const { const { saveMessage, getConvo, + getChatProject, getMessages, + getProjectFiles, getFiles, getAgent, getActions, @@ -199,7 +204,6 @@ async function deleteFailedResumeCheckpoint(args, context) { } const GENERIC_RESUME_ERROR = 'Resume failed'; - const resumeContentProtectionDependencies = { onTraversalFailure: reportLocatorTraversalFailure, getAgentCheckpointer, @@ -1303,6 +1307,18 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle) let eventActorActionRecorder; let appliedEventActor; const providerExecutionId = randomUUID(); + let resumeAcknowledged = false; + let resumeSlotReleased = false; + const rejectPendingEventActorResume = async (error) => { + if ( + eventActorResumePromise != null && + eventActorStartGate != null && + !eventActorContinuationStarted + ) { + eventActorStartGate.reject(error); + await eventActorResumePromise.catch(() => {}); + } + }; try { if (req._agentEventBindingParentConversationId != null) { req._agentEventTaskId = job.metadata.idempotencyClientRequestId; @@ -1686,508 +1702,549 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle) generationProtocolVersion, ); } - - // Linearize the consumed approval against the schedule's live config. The schedule - // document fence was acquired only after all async policy reads, and this atomic - // consume checks its token/revision/enabled state immediately after the approval CAS. - // An edit/disable that won first makes this fail; one that lands afterward is ordered - // after the continuation has started. Never begin provider execution on a stale claim. - if (scheduleId) { - let scheduleClaimCurrent = false; - try { - scheduleClaimCurrent = await finalizeScheduleResumeClaim( - scheduleId, - scheduleResumeClaimToken, - scheduleResumeLeaseBy, - scheduleResumeOptions, - ); - } catch (error) { - logger.error('[ResumeAgentController] Failed to finalize scheduled resume fence', error); - await releaseScheduleFence(); - } - if (!scheduleClaimCurrent) { - await decrementPendingRequest(userId); - let stopped = false; + let client = null; + /** Re-pause progress failures use the action/epoch-scoped terminal CAS. The + * generic resume catch must not subsequently call completeJob, because the + * failed pause may have lost ownership to a newer action or generation. */ + let pausePersistenceFailed = false; + let pausePersistenceFailureFinalized = false; + try { + // Linearize the consumed approval against the schedule's live config. The schedule + // document fence was acquired only after all async policy reads, and this atomic + // consume checks its token/revision/enabled state immediately after the approval CAS. + // An edit/disable that won first makes this fail; one that lands afterward is ordered + // after the continuation has started. Never begin provider execution on a stale claim. + if (scheduleId) { + let scheduleClaimCurrent = false; try { - const abortResult = await GenerationJobManager.abortJob(streamId, { - expectedCreatedAt: job.createdAt, - awaitProviderDrain: true, - }); - // Same authoritative gate as the inactive-schedule path above: only a landed - // abort (or an already-terminal, drained generation) may settle this occurrence. - stopped = isStopConfirmed(abortResult); + scheduleClaimCurrent = await finalizeScheduleResumeClaim( + scheduleId, + scheduleResumeClaimToken, + scheduleResumeLeaseBy, + scheduleResumeOptions, + ); } catch (error) { - logger.warn('[ResumeAgentController] Failed to stop stale scheduled resume', error); + logger.error('[ResumeAgentController] Failed to finalize scheduled resume fence', error); + await releaseScheduleFence(); } - if (!stopped) { - res.set('Retry-After', '1'); + if (!scheduleClaimCurrent) { + resumeSlotReleased = true; + await decrementPendingRequest(userId); + let stopped = false; + try { + const abortResult = await GenerationJobManager.abortJob(streamId, { + expectedCreatedAt: job.createdAt, + awaitProviderDrain: true, + }); + // Same authoritative gate as the inactive-schedule path above: only a landed + // abort (or an already-terminal, drained generation) may settle this occurrence. + stopped = isStopConfirmed(abortResult); + } catch (error) { + logger.warn('[ResumeAgentController] Failed to stop stale scheduled resume', error); + } + if (!stopped) { + res.set('Retry-After', '1'); + return sendGenerationJson( + res, + 503, + { + code: 'SCHEDULE_STOP_UNCONFIRMED', + error: 'The stale scheduled resume could not be confirmed stopped.', + }, + generationProtocolVersion, + ); + } + await recordScheduleOutcome({ + scheduleId, + scheduledFor, + streamId, + jobCreatedAt: job.createdAt, + status: 'interrupted', + conversationId, + error: 'Schedule was disabled, changed, or deleted before approval', + }); + if (checkpointNamespace !== '') { + await deleteAgentCheckpoint(conversationId, checkpointerCfg, undefined, { + checkpointNamespace, + }).catch((error) => { + logger.warn( + '[ResumeAgentController] Failed to prune stale schedule checkpoint', + error, + ); + }); + } return sendGenerationJson( res, - 503, - { - code: 'SCHEDULE_STOP_UNCONFIRMED', - error: 'The stale scheduled resume could not be confirmed stopped.', - }, + 409, + { code: 'SCHEDULE_NO_LONGER_ACTIVE', error: 'This schedule can no longer be resumed' }, generationProtocolVersion, ); } - await recordScheduleOutcome({ - scheduleId, - scheduledFor, - streamId, - jobCreatedAt: job.createdAt, - status: 'interrupted', - conversationId, - error: 'Schedule was disabled, changed, or deleted before approval', - }); - if (checkpointNamespace !== '') { - await deleteAgentCheckpoint(conversationId, checkpointerCfg, undefined, { - checkpointNamespace, + } + // Re-read the authoritative conversation/project only after approvals.resolve + // wins its CAS. The approval CAS/epoch is the owner fence: a mismatch + // terminalizes only this claimed job before initializeClient can start provider + // or tool work. + const claimedProjectContextConflict = await rejectChangedResumeProjectContext( + { req, conversationId, expectedKey: pendingAction?.projectContextKey }, + { + getConvo, + getChatProject, + getProjectFiles, + logger, + finalizeJob: async (reason) => + (await GenerationJobManager.completeJob(streamId, reason, job.createdAt)) === true, + deleteCheckpoint: () => + deleteResumedGenerationCheckpoint({ + conversationId, + checkpointerCfg, + job, + checkpointGeneration, + }), + }, + ); + if (claimedProjectContextConflict) { + sendGenerationJson(res, 409, PROJECT_CONTEXT_CHANGED_RESPONSE, generationProtocolVersion); + await rejectPendingEventActorResume(new Error(PROJECT_CONTEXT_CHANGED_REASON)); + resumeSlotReleased = true; + await decrementPendingRequest(userId); + await releaseScheduleFence(); + if (scheduleId) { + await recordScheduleOutcome({ + scheduleId, + scheduledFor, + streamId, + jobCreatedAt: job.createdAt, + status: 'interrupted', + conversationId, + error: PROJECT_CONTEXT_CHANGED_REASON, }).catch((error) => { - logger.warn('[ResumeAgentController] Failed to prune stale schedule checkpoint', error); + logger.warn( + '[ResumeAgentController] Failed to record stale project-context schedule outcome', + getSafeErrorMetadata(error), + ); }); } - return sendGenerationJson( - res, - 409, - { code: 'SCHEDULE_NO_LONGER_ACTIVE', error: 'This schedule can no longer be resumed' }, - generationProtocolVersion, - ); + return; } - } - - eventLeaseTransferredToRun = true; - } finally { - if (!eventLeaseTransferredToRun) { - await releaseEventChildLease?.(); - releaseEventChildLease = undefined; - } - } - - /** - * An interrupt steer enqueued just before the pause survives durably with - * its `preempt` flag, but the ARM lived only in the previous owner's - * runtime. Rebuild it from the queue so the resumed segment honours an - * interrupt the user already had acknowledged. - */ - const preemptRearm = GenerationJobManager.rearmQueuedPreempts(streamId, job.createdAt).catch( - (error) => { - logger.error( - '[ResumeAgentController] Failed to re-arm queued preempts', - getSafeErrorMetadata(error), + eventLeaseTransferredToRun = true; + + /** + * An interrupt steer enqueued just before the pause survives durably with + * its `preempt` flag, but the ARM lived only in the previous owner's + * runtime. Rebuild it from the queue so the resumed segment honours an + * interrupt the user already had acknowledged. + */ + const preemptRearm = GenerationJobManager.rearmQueuedPreempts(streamId, job.createdAt).catch( + (error) => { + logger.error( + '[ResumeAgentController] Failed to re-arm queued preempts', + getSafeErrorMetadata(error), + ); + }, ); - }, - ); - - /** - * BOUNDED, and the bound is the point. `.catch` only fires on rejection, - * but ioredis queues commands while a connection is down instead of - * rejecting, so either of these can simply never settle. That would block - * here — after `approvals.resolve` has already consumed the action and - * flipped the job to `running`, and before both `res.json` and the resume - * lifecycle's own try/finally. The client times out, its retry gets a 409 - * because the action is spent, and neither the continuation nor the - * failed-resume cleanup ever runs. - * - * Re-arming is steering bookkeeping that the next tool boundary would - * honour anyway, so it finishes in the background rather than holding a - * resume the user is waiting on. Capability is not in this best-effort path: - * it was committed atomically by the resume claim above. - */ - let steeringSetupTimer; - await Promise.race([ - preemptRearm, - new Promise((resolve) => { - steeringSetupTimer = setTimeout(() => { - logger.warn( - `[ResumeAgentController] Steering setup for ${streamId} still pending after ` + - `${STEER_RESUME_SETUP_TIMEOUT_MS}ms; continuing the resume without it`, - ); - resolve(); - }, STEER_RESUME_SETUP_TIMEOUT_MS); - }), - ]); - clearTimeout(steeringSetupTimer); - // Seed the run-scoped MCP request-context store BEFORE the ACK: once `res.json` - // finishes the response, a later `getMCPRequestContext(req, res)` (from tool loading) - // sees `res` as ended and returns undefined, leaving the resumed run without its MCP - // connection store — approved MCP / OAuth-overlay tools would then run without their - // request-scoped connections. Pre-seeding with a null `res` + `cleanupOnResponse:false` - // mirrors the normal stream path (request.js); torn down in the `finally` below. - req._resumableStreamId = streamId; - getMCPRequestContext(req, undefined, { cleanupOnResponse: false }); - - // ACK immediately; the continuation streams over the client's existing SSE. - sendGenerationJson( - res, - 200, - { streamId, conversationId, status: 'resuming' }, - generationProtocolVersion, - ); + /** + * BOUNDED, and the bound is the point. `.catch` only fires on rejection, + * but ioredis queues commands while a connection is down instead of + * rejecting, so either of these can simply never settle. Keep steering setup + * bounded so it cannot hold the resume before the ACK or its lifecycle cleanup. + * + * Re-arming is steering bookkeeping that the next tool boundary would + * honour anyway, so it finishes in the background rather than holding the + * resume the user is waiting on. Capability is not in this best-effort path: + * it was committed atomically by the resume claim above. + */ + let steeringSetupTimer; + await Promise.race([ + preemptRearm, + new Promise((resolve) => { + steeringSetupTimer = setTimeout(() => { + logger.warn( + `[ResumeAgentController] Steering setup for ${streamId} still pending after ` + + `${STEER_RESUME_SETUP_TIMEOUT_MS}ms; continuing the resume without it`, + ); + resolve(); + }, STEER_RESUME_SETUP_TIMEOUT_MS); + }), + ]); + clearTimeout(steeringSetupTimer); + + // Seed the run-scoped MCP request-context store BEFORE the ACK: once `res.json` + // finishes the response, a later `getMCPRequestContext(req, res)` (from tool loading) + // sees `res` as ended and returns undefined, leaving the resumed run without its MCP + // connection store; approved MCP / OAuth-overlay tools would then run without their + // request-scoped connections. Pre-seeding with a null `res` + `cleanupOnResponse:false` + // mirrors the normal stream path (request.js); torn down in the `finally` below. + req._resumableStreamId = streamId; + getMCPRequestContext(req, undefined, { cleanupOnResponse: false }); + + sendGenerationJson( + res, + 200, + { streamId, conversationId, status: 'resuming' }, + generationProtocolVersion, + ); + resumeAcknowledged = true; - req.turnStartedAt = job.createdAt; + req.turnStartedAt = job.createdAt; - let client = null; - /** Re-pause progress failures use the action/epoch-scoped terminal CAS. The - * generic resume catch must not subsequently call completeJob, because the - * failed pause may have lost ownership to a newer action or generation. */ - let pausePersistenceFailed = false; - let pausePersistenceFailureFinalized = false; - try { - if (userSubmittedPaths.length > 0) { - job.metadata.userSubmittedPaths = userSubmittedPaths; - } - if (userSubmittedMessageFieldPaths.length > 0) { - job.metadata.userSubmittedMessageFieldPaths = userSubmittedMessageFieldPaths; - } - if (userSubmittedPaths.length > 0 || userSubmittedMessageFieldPaths.length > 0) { - await GenerationJobManager.getJobStore().updateJob( - streamId, - { - ...(userSubmittedPaths.length > 0 && { userSubmittedPaths }), - ...(userSubmittedMessageFieldPaths.length > 0 && { - userSubmittedMessageFieldPaths, - }), - }, - job.createdAt, - ); - } + if (userSubmittedPaths.length > 0) { + job.metadata.userSubmittedPaths = userSubmittedPaths; + } + if (userSubmittedMessageFieldPaths.length > 0) { + job.metadata.userSubmittedMessageFieldPaths = userSubmittedMessageFieldPaths; + } + if (userSubmittedPaths.length > 0 || userSubmittedMessageFieldPaths.length > 0) { + await GenerationJobManager.getJobStore().updateJob( + streamId, + { + ...(userSubmittedPaths.length > 0 && { userSubmittedPaths }), + ...(userSubmittedMessageFieldPaths.length > 0 && { + userSubmittedMessageFieldPaths, + }), + }, + job.createdAt, + ); + } - const mcpRequestBody = - job.metadata.mcpRequestBody ?? - createMCPRuntimeRequestBody({ - messageId: job.metadata.responseMessageId, - conversationId: streamId, - codeEnvironmentMode: - req.body.codeEnvironmentMode ?? req.resolvedConversation?.codeEnvironmentMode, - codeWorkspaces: req.body.codeWorkspaces ?? req.resolvedConversation?.codeWorkspaces, - parentMessageId: job.metadata.userMessage?.messageId ?? Constants.NO_PARENT, + const mcpRequestBody = + job.metadata.mcpRequestBody ?? + createMCPRuntimeRequestBody({ + messageId: job.metadata.responseMessageId, + conversationId: streamId, + codeEnvironmentMode: + req.body.codeEnvironmentMode ?? req.resolvedConversation?.codeEnvironmentMode, + codeWorkspaces: req.body.codeWorkspaces ?? req.resolvedConversation?.codeWorkspaces, + parentMessageId: job.metadata.userMessage?.messageId ?? Constants.NO_PARENT, + }); + const result = await initializeClient({ + scheduledTokenContext: restoreScheduledTokenContext(req, job.metadata), + req, + res, + endpointOption: req.body.endpointOption, + signal: job.abortController.signal, + jobCreatedAt: job.createdAt, + checkpointNamespace, + foregroundRunId: mcpRequestBody.messageId, + requestBody: mcpRequestBody, }); - const result = await initializeClient({ - scheduledTokenContext: restoreScheduledTokenContext(req, job.metadata), - req, - res, - endpointOption: req.body.endpointOption, - signal: job.abortController.signal, - jobCreatedAt: job.createdAt, - checkpointNamespace, - foregroundRunId: mcpRequestBody.messageId, - requestBody: mcpRequestBody, - }); - client = result.client; + client = result.client; - // The user approved the code action against the route/session selected at - // pause time. Re-resolve it on this replica and fail before provider/tool - // execution if the environment, worker, or workspace scope moved. - assertCodeExecutionApprovalBinding( - pendingAction.codeExecutionBinding, - collectReachableAgents([client.options?.agent, ...(client.agentConfigs?.values() ?? [])]), - ); + // Re-resolve the approved code target before provider/tool execution on this replica. + assertCodeExecutionApprovalBinding( + pendingAction.codeExecutionBinding, + collectReachableAgents([client.options?.agent, ...(client.agentConfigs?.values() ?? [])]), + ); - // Bind the rebuilt client to the in-flight turn's identity (no new user message). - client.conversationId = streamId; - // The resume operates on the SAME job (it moved it running again), so its identity is - // the paused job's createdAt — used by the re-pause CAS pre-check + checkpoint prune to - // avoid acting on a job a newer request has since replaced. - client.jobCreatedAt = job.createdAt; - client.checkpointNamespace = checkpointNamespace; - client.responseMessageId = job.metadata.responseMessageId; - client.parentMessageId = job.metadata.userMessage?.messageId ?? Constants.NO_PARENT; - // Seed the rebuilt pruner from the tier and calibration captured at the pause, so the - // resumed segment keeps historical tool results byte-identical to the paused one. - client.seedContextMeta?.(job.metadata?.contextMeta); - if (client.contentParts) { - GenerationJobManager.setContentParts(streamId, client.contentParts, job.createdAt); - } + // Bind the rebuilt client to the in-flight turn's identity (no new user message). + client.conversationId = streamId; + // The resume operates on the SAME job (it moved it running again), so its identity is + // the paused job's createdAt, used by the re-pause CAS pre-check + checkpoint prune to + // avoid acting on a job a newer request has since replaced. + client.jobCreatedAt = job.createdAt; + client.checkpointNamespace = checkpointNamespace; + client.responseMessageId = job.metadata.responseMessageId; + client.parentMessageId = job.metadata.userMessage?.messageId ?? Constants.NO_PARENT; + // Seed the rebuilt pruner from the tier and calibration captured at the pause, so the + // resumed segment keeps historical tool results byte-identical to the paused one. + client.seedContextMeta?.(job.metadata?.contextMeta); + if (client.contentParts) { + GenerationJobManager.setContentParts(streamId, client.contentParts, job.createdAt); + } - const resumeClient = () => - client.resumeCompletion({ - resumeValue: mapped.resumeValue, - seedContent, - runSteps: resumeState?.runSteps ?? [], - storedMessages, - abortController: job.abortController, - // Carry the user's MCP auth so approved MCP tools run with their credentials. - userMCPAuthMap: result.userMCPAuthMap, - // Replay deferred tools discovered before the pause (captured at pause). The rebuilt - // graph passes `messages: []`, so without these the model would lose their schemas. - discoveredToolNames: job.metadata?.discoveredTools, - activityPhaseSnapshot: job.metadata?.activityPhaseSnapshot, - compactionSemanticIndex: job.metadata?.compactionSemanticIndex, - }); - if ( - !(await GenerationJobManager.beginProviderExecution( - streamId, - job.createdAt, - providerExecutionId, - )) - ) { - throw Object.assign(new Error('Generation stopped before provider resume'), { - code: 'RUN_REPLACED', - }); - } - if (eventActorResumePromise == null) { - await resumeClient(); - } else { - eventActorContinuationStarted = true; - eventActorStartGate.resolve(async (actorContext) => { - client.checkpointNamespace = actorContext.checkpointNamespace; - client.eventActorCheckpointId = actorContext.checkpointId; - client.eventActorInvocationId = actorContext.invocationId; - client.eventActorContinuation = actorContext.continuation; - return resumeClient(); - }); - const actorResult = await eventActorResumePromise; - if (actorResult.execution.status === 'suspended') { - const suspensionKind = req._agentEventDetachedActionLifecycle?.readSuspension()?.kind; - if (suspensionKind === 'internal_completion') { - await GenerationJobManager.updateMetadata( - streamId, - { - agentEventSuspension: { - version: actorResult.execution.suspension.version, - suspensionId: actorResult.execution.suspension.suspensionId, - attempt: actorResult.execution.suspension.attempt, + const resumeClient = () => + client.resumeCompletion({ + resumeValue: mapped.resumeValue, + seedContent, + runSteps: resumeState?.runSteps ?? [], + storedMessages, + abortController: job.abortController, + // Carry the user's MCP auth so approved MCP tools run with their credentials. + userMCPAuthMap: result.userMCPAuthMap, + // Replay deferred tools discovered before the pause (captured at pause). The rebuilt + // graph passes `messages: []`, so without these the model would lose their schemas. + discoveredToolNames: job.metadata?.discoveredTools, + activityPhaseSnapshot: job.metadata?.activityPhaseSnapshot, + compactionSemanticIndex: job.metadata?.compactionSemanticIndex, + }); + if ( + !(await GenerationJobManager.beginProviderExecution( + streamId, + job.createdAt, + providerExecutionId, + )) + ) { + throw Object.assign(new Error('Generation stopped before provider resume'), { + code: 'RUN_REPLACED', + }); + } + if (eventActorResumePromise == null) { + await resumeClient(); + } else { + eventActorContinuationStarted = true; + eventActorStartGate.resolve(async (actorContext) => { + client.checkpointNamespace = actorContext.checkpointNamespace; + client.eventActorCheckpointId = actorContext.checkpointId; + client.eventActorInvocationId = actorContext.invocationId; + client.eventActorContinuation = actorContext.continuation; + return resumeClient(); + }); + const actorResult = await eventActorResumePromise; + if (actorResult.execution.status === 'suspended') { + const suspensionKind = req._agentEventDetachedActionLifecycle?.readSuspension()?.kind; + if (suspensionKind === 'internal_completion') { + await GenerationJobManager.updateMetadata( + streamId, + { + agentEventSuspension: { + version: actorResult.execution.suspension.version, + suspensionId: actorResult.execution.suspension.suspensionId, + attempt: actorResult.execution.suspension.attempt, + }, }, - }, - job.createdAt, - ); - } else if (!(await client.publishStagedApproval(actorResult.execution.suspension))) { - throw new Error('Re-paused event actor suspension could not be projected to its job'); + job.createdAt, + ); + } else if (!(await client.publishStagedApproval(actorResult.execution.suspension))) { + throw new Error('Re-paused event actor suspension could not be projected to its job'); + } + } else if (actorResult.execution.status === 'applied') { + appliedEventActor = { + invocationId: durableEventActorSuspension.invocation.invocationId, + checkpoint: actorResult.execution.head.checkpoint, + action: actorResult.execution.result.action, + }; } - } else if (actorResult.execution.status === 'applied') { - appliedEventActor = { - invocationId: durableEventActorSuspension.invocation.invocationId, - checkpoint: actorResult.execution.head.checkpoint, - action: actorResult.execution.result.action, - }; } - } - // The model may pause AGAIN (another tool, or a follow-up question). The pending - // action is durably projected; persist progress before exposing it to clients. - if (client.pendingApproval) { - logger.debug(`[ResumeAgentController] Re-paused for approval: ${streamId}`); - const pauseActionId = client.pendingApproval.actionId; - const pauseCreatedAt = client.jobCreatedAt ?? job.createdAt; - const ownsPausePersistence = await GenerationJobManager.approvals.ownsPausePersistence( - streamId, - pauseActionId, - pauseCreatedAt, - ); - if (ownsPausePersistence) { - try { - // Persist this segment's content + artifacts before the fresh client (next - // resume) drops them, so an expiring re-pause doesn't lose them; finalize later - // overwrites content and merges attachments onto the saved message. A failed - // required write must reject into the error-finalization path rather than expose - // the next action while its preceding segment is absent from durable history. - await persistRePauseProgress({ req, client, job, streamId, conversationId }); - } catch (pausePersistenceError) { - pausePersistenceFailed = true; + // The model may pause AGAIN (another tool, or a follow-up question). The pending + // action is durably projected; persist progress before exposing it to clients. + if (client.pendingApproval) { + logger.debug(`[ResumeAgentController] Re-paused for approval: ${streamId}`); + const pauseActionId = client.pendingApproval.actionId; + const pauseCreatedAt = client.jobCreatedAt ?? job.createdAt; + const ownsPausePersistence = await GenerationJobManager.approvals.ownsPausePersistence( + streamId, + pauseActionId, + pauseCreatedAt, + ); + if (ownsPausePersistence) { try { - pausePersistenceFailureFinalized = - (await GenerationJobManager.failPausePersistence( - streamId, - pauseActionId, - getUserFacingResumeError(pausePersistenceError, req.config), - pauseCreatedAt, - )) === true; - if (!pausePersistenceFailureFinalized) { - logger.warn( - `[ResumeAgentController] Skipping stale re-pause persistence failure — ${streamId} no longer owns its barrier`, + // Persist this segment's content + artifacts before the fresh client (next + // resume) drops them, so an expiring re-pause doesn't lose them; finalize later + // overwrites content and merges attachments onto the saved message. A failed + // required write must reject into the error-finalization path rather than expose + // the next action while its preceding segment is absent from durable history. + await persistRePauseProgress({ req, client, job, streamId, conversationId }); + } catch (pausePersistenceError) { + pausePersistenceFailed = true; + try { + pausePersistenceFailureFinalized = + (await GenerationJobManager.failPausePersistence( + streamId, + pauseActionId, + getUserFacingResumeError(pausePersistenceError, req.config), + pauseCreatedAt, + )) === true; + if (!pausePersistenceFailureFinalized) { + logger.warn( + `[ResumeAgentController] Skipping stale re-pause persistence failure: ${streamId} no longer owns its barrier`, + ); + } + } catch (failError) { + logger.error( + `[ResumeAgentController] Failed to terminalize re-pause persistence error for ${streamId}`, + getSafeErrorMetadata(failError), ); } - } catch (failError) { - logger.error( - `[ResumeAgentController] Failed to terminalize re-pause persistence error for ${streamId}`, - getSafeErrorMetadata(failError), + throw pausePersistenceError; + } + await client.exposePendingApproval?.(); + const released = await GenerationJobManager.approvals.finishPausePersistence( + streamId, + pauseActionId, + pauseCreatedAt, + ); + if (!released) { + logger.warn( + `[ResumeAgentController] Re-pause persistence barrier changed before release: ${streamId}`, ); } - throw pausePersistenceError; - } - await client.exposePendingApproval?.(); - const released = await GenerationJobManager.approvals.finishPausePersistence( - streamId, - pauseActionId, - pauseCreatedAt, - ); - if (!released) { - logger.warn( - `[ResumeAgentController] Re-pause persistence barrier changed before release: ${streamId}`, + if (scheduleId) { + await recordScheduleOutcome({ + scheduleId, + scheduledFor, + streamId, + jobCreatedAt: job.createdAt, + status: 'requires_action', + conversationId, + checkpointNamespace: client.checkpointNamespace, + }); + } + } else { + logger.debug( + `[ResumeAgentController] Skipping stale re-pause persistence: ${streamId} no longer owns its barrier`, ); } + return; + } + + // If the user aborted mid-resume, the abort route already emitted the terminal + // event and finalized the job; don't double-save / double-finalize here. This + // continuation is nevertheless the scheduled-run owner, so it must settle the + // run row after observing its own abort; the generic Stop route deliberately + // delegates a running generation's settlement to that generation owner. + if (job.abortController.signal.aborted) { + logger.debug( + `[ResumeAgentController] Aborted during resume; abort route finalizes: ${streamId}`, + ); if (scheduleId) { await recordScheduleOutcome({ scheduleId, scheduledFor, streamId, jobCreatedAt: job.createdAt, - status: 'requires_action', + status: 'interrupted', conversationId, - checkpointNamespace: client.checkpointNamespace, + error: 'Scheduled run was stopped', }); } - } else { - logger.debug( - `[ResumeAgentController] Skipping stale re-pause persistence — ${streamId} no longer owns its barrier`, - ); + return; } - return; - } - // If the user aborted mid-resume, the abort route already emitted the terminal - // event and finalized the job — don't double-save / double-finalize here. This - // continuation is nevertheless the scheduled-run owner, so it must settle the - // run row after observing its own abort; the generic Stop route deliberately - // delegates a running generation's settlement to that generation owner. - if (job.abortController.signal.aborted) { - logger.debug( - `[ResumeAgentController] Aborted during resume; abort route finalizes: ${streamId}`, - ); - if (scheduleId) { - await recordScheduleOutcome({ - scheduleId, - scheduledFor, - streamId, - jobCreatedAt: job.createdAt, - status: 'interrupted', - conversationId, - error: 'Scheduled run was stopped', - }); - } - return; - } - - await finalizeResumedTurn({ - req, - client, - job, - streamId, - conversationId, - addTitle, - checkpointGeneration, - appliedEventActor, - }); - } catch (err) { - if ( - eventActorResumePromise != null && - eventActorStartGate != null && - !eventActorContinuationStarted - ) { - eventActorStartGate.reject(err); - await eventActorResumePromise.catch(() => {}); - } - logger.error('[ResumeAgentController] Resume failed', getSafeErrorMetadata(err)); - if (pausePersistenceFailed) { - // failPausePersistence already performed the exact requires_action -> - // error transition. Only its CAS winner owns this generation's checkpoint - // cleanup; a stale/mismatched failure must leave the live scope intact. - if (pausePersistenceFailureFinalized) { - await deleteFailedResumeCheckpoint( - { + await finalizeResumedTurn({ + req, + client, + job, + streamId, + conversationId, + addTitle, + checkpointGeneration, + appliedEventActor, + }); + } catch (err) { + await rejectPendingEventActorResume(err); + logger.error('[ResumeAgentController] Resume failed', getSafeErrorMetadata(err)); + await releaseScheduleFence(); + if (pausePersistenceFailed) { + // failPausePersistence already performed the exact requires_action -> + // error transition. Only its CAS winner owns this generation's checkpoint + // cleanup; a stale/mismatched failure must leave the live scope intact. + if (pausePersistenceFailureFinalized) { + await deleteFailedResumeCheckpoint( + { + conversationId, + checkpointerCfg, + job, + checkpointGeneration, + }, + 're-pause persistence failure', + ); + } + if (scheduleId && pausePersistenceFailureFinalized) { + await recordScheduleOutcome({ + scheduleId, + scheduledFor, + streamId, + jobCreatedAt: job.createdAt, + status: 'error', conversationId, - checkpointerCfg, - job, - checkpointGeneration, - }, - 're-pause persistence failure', - ); - } - if (scheduleId && pausePersistenceFailureFinalized) { - await recordScheduleOutcome({ - scheduleId, - scheduledFor, - streamId, - jobCreatedAt: job.createdAt, - status: 'error', - conversationId, - error: err?.message ?? 'Re-pause persistence failed', - }); + error: err?.message ?? 'Re-pause persistence failed', + }); + } + return; } - return; - } - const errorMessage = getUserFacingResumeError(err, req.config); - // Job-replacement guard (mirrors finalizeResumedTurn's success-path guard): if a - // newer request reused this conversationId while the resume was failing, do NOT emit - // the error to / complete / prune the NEWER turn's job. The finally still releases - // the slot + disposes. Proceed with finalization if the replacement check itself fails. - let stillLive = true; - try { - const liveJob = await GenerationJobManager.getJobStore().getJob(streamId); - stillLive = !!liveJob && liveJob.createdAt === job.createdAt; - } catch (readErr) { - logger.warn( - '[ResumeAgentController] Replacement check failed; finalizing anyway', - getSafeErrorMetadata(readErr), - ); - } - if (!stillLive) { - logger.warn( - `[ResumeAgentController] Skipping failed-resume finalization — job ${streamId} was replaced`, - ); - } else { - // completeJob atomically claims running -> error and parks steers before - // publishing. If abort or a re-pause won, it returns false; only the - // terminal-CAS winner may delete this generation's checkpoint scope. - let errorFinalized = false; + const errorMessage = getUserFacingResumeError(err, req.config); + // Job-replacement guard (mirrors finalizeResumedTurn's success-path guard): if a + // newer request reused this conversationId while the resume was failing, do NOT emit + // the error to / complete / prune the NEWER turn's job. The finally still releases + // the slot + disposes. Proceed with finalization if the replacement check itself fails. + let stillLive = true; try { - errorFinalized = - (await GenerationJobManager.completeJob(streamId, errorMessage, job.createdAt)) === true; - } catch (completeErr) { - logger.error( - '[ResumeAgentController] Failed to finalize failed resume', - getSafeErrorMetadata(completeErr), + const liveJob = await GenerationJobManager.getJobStore().getJob(streamId); + stillLive = !!liveJob && liveJob.createdAt === job.createdAt; + } catch (readErr) { + logger.warn( + '[ResumeAgentController] Replacement check failed; finalizing anyway', + getSafeErrorMetadata(readErr), ); } - if (errorFinalized) { - await deleteFailedResumeCheckpoint( - { - conversationId, - checkpointerCfg, - job, - checkpointGeneration, - }, - 'failed resume finalization', + if (!stillLive) { + logger.warn( + `[ResumeAgentController] Skipping failed-resume finalization: job ${streamId} was replaced`, ); + } else { + // completeJob atomically claims running -> error and parks steers before + // publishing. If abort or a re-pause won, it returns false; only the + // terminal-CAS winner may delete this generation's checkpoint scope. + let errorFinalized = false; + try { + errorFinalized = + (await GenerationJobManager.completeJob(streamId, errorMessage, job.createdAt)) === + true; + } catch (completeErr) { + logger.error( + '[ResumeAgentController] Failed to finalize failed resume', + getSafeErrorMetadata(completeErr), + ); + } + if (errorFinalized) { + await deleteFailedResumeCheckpoint( + { + conversationId, + checkpointerCfg, + job, + checkpointGeneration, + }, + 'failed resume finalization', + ); + } + if (scheduleId && errorFinalized) { + const balanceRefusal = err?.message?.includes(ViolationTypes.TOKEN_BALANCE); + await recordScheduleOutcome({ + scheduleId, + scheduledFor, + streamId, + jobCreatedAt: job.createdAt, + status: balanceRefusal ? 'skipped_balance' : 'error', + conversationId, + ...(!balanceRefusal && { error: err?.message ?? 'Resume failed' }), + }); + } + } + if (!resumeAcknowledged && !res.headersSent) { + sendGenerationJson(res, 500, { error: GENERIC_RESUME_ERROR }, generationProtocolVersion); } - if (scheduleId && errorFinalized) { - const balanceRefusal = err?.message?.includes(ViolationTypes.TOKEN_BALANCE); - await recordScheduleOutcome({ - scheduleId, - scheduledFor, + } finally { + try { + // Tear down the MCP request-context store seeded before the ACK (parity with + // request.js's finishResumableRequest). No-op if it was never seeded. + await cleanupMCPRequestContextForReq(req); + // Release the concurrency slot taken above, UNLESS handleRunInterrupt already + // released it on a re-pause (so a fast /resume isn't 429'd). On a normal finish or + // error it didn't, so release here. A re-pause re-acquires its own slot next resume. + if (!resumeSlotReleased && !client?.pendingRequestReleased) { + await decrementPendingRequest(userId); + } + if (client) { + disposeClient(client); + } + } finally { + await GenerationJobManager.markProviderExecutionDrained?.( streamId, - jobCreatedAt: job.createdAt, - status: balanceRefusal ? 'skipped_balance' : 'error', - conversationId, - ...(!balanceRefusal && { error: err?.message ?? 'Resume failed' }), + job.createdAt, + providerExecutionId, + ).catch((drainError) => { + logger.warn('[ResumeAgentController] Failed to record provider drain', drainError); }); + await releaseEventChildLease?.(); + releaseEventChildLease = undefined; } } } finally { - try { - // Tear down the MCP request-context store seeded before the ACK (parity with - // request.js's finishResumableRequest). No-op if it was never seeded. - await cleanupMCPRequestContextForReq(req); - // Release the concurrency slot taken above — UNLESS handleRunInterrupt already - // released it on a re-pause (so a fast /resume isn't 429'd). On a normal finish or - // error it didn't, so release here. A re-pause re-acquires its own slot next resume. - if (!client?.pendingRequestReleased) { - await decrementPendingRequest(userId); - } - if (client) { - disposeClient(client); - } - } finally { - await GenerationJobManager.markProviderExecutionDrained?.( - streamId, - job.createdAt, - providerExecutionId, - ).catch((drainError) => { - logger.warn('[ResumeAgentController] Failed to record provider drain', drainError); - }); + if (!eventLeaseTransferredToRun) { await releaseEventChildLease?.(); + releaseEventChildLease = undefined; } } }; diff --git a/api/server/controllers/assistants/chat.contentFilter.spec.js b/api/server/controllers/assistants/chat.contentFilter.spec.js index fe3fb8cf919..2b740837321 100644 --- a/api/server/controllers/assistants/chat.contentFilter.spec.js +++ b/api/server/controllers/assistants/chat.contentFilter.spec.js @@ -6,12 +6,18 @@ const mockStreamRunManager = jest.fn(); const mockSendEvent = jest.fn(); const mockSaveUserMessage = jest.fn(); const mockSaveAssistantMessage = jest.fn(); -const mockCreateRunBody = jest.fn(); +const mockCountTokens = jest.fn(); +const mockCheckBalance = jest.fn(); +const mockGetBalanceConfig = jest.fn(); +const mockGetModelMaxTokens = jest.fn(); +const mockGetTransactions = jest.fn(); const mockSendResponse = jest.fn(); +const mockCreateRunBody = jest.fn(); const mockHandleError = jest.fn(); const mockRetrieveAssistant = jest.fn(); const mockListThreadMessages = jest.fn(); const mockGetConvo = jest.fn(); +const mockGetChatProject = jest.fn(); const mockGetFiles = jest.fn(); const mockEncodeAndFormat = jest.fn(); const mockGetOpenAIClient = jest.fn().mockResolvedValue({ @@ -43,20 +49,20 @@ jest.mock('@librechat/data-schemas', () => ({ ...jest.requireActual('@librechat/data-schemas'), logger: { debug: jest.fn(), + info: jest.fn(), error: jest.fn(), warn: jest.fn(), }, })); - jest.mock('@librechat/api', () => { const actual = jest.requireActual('../../../../packages/api/dist/index.cjs'); return { ...actual, sendEvent: (...args) => mockSendEvent(...args), - countTokens: jest.fn(), - checkBalance: jest.fn(), - getBalanceConfig: jest.fn(), - getModelMaxTokens: jest.fn(), + countTokens: (...args) => mockCountTokens(...args), + checkBalance: (...args) => mockCheckBalance(...args), + getBalanceConfig: (...args) => mockGetBalanceConfig(...args), + getModelMaxTokens: (...args) => mockGetModelMaxTokens(...args), }; }); @@ -117,9 +123,11 @@ jest.mock('~/models', () => ({ releaseBalanceReservation: jest.fn(), renewBalanceReservation: jest.fn(), reserveBalance: jest.fn(), - getTransactions: jest.fn(), + getTransactions: (...args) => mockGetTransactions(...args), getMultiplier: jest.fn(), getConvo: (...args) => mockGetConvo(...args), + getChatProject: (...args) => mockGetChatProject(...args), + getProjectFiles: (...args) => mockGetFiles(...args), getFiles: (...args) => mockGetFiles(...args), })); @@ -139,7 +147,6 @@ jest.mock('./helpers', () => ({ const chatV1 = require('./chatV1'); const chatV2 = require('./chatV2'); const { logger } = require('@librechat/data-schemas'); -const { checkBalance, getBalanceConfig } = require('@librechat/api'); const { ImageVisionTool } = require('librechat-data-provider'); describe.each([ @@ -152,6 +159,12 @@ describe.each([ beforeEach(() => { jest.clearAllMocks(); + mockCountTokens.mockReset().mockResolvedValue(0); + mockCheckBalance.mockReset().mockResolvedValue(undefined); + mockGetBalanceConfig.mockReset().mockReturnValue({ enabled: false }); + mockGetModelMaxTokens.mockReset().mockReturnValue(100000); + mockGetTransactions.mockReset().mockResolvedValue([]); + mockCreateRunBody.mockReset().mockReturnValue({}); mockRetrieveAssistant.mockReset().mockResolvedValue({ id: 'asst-1', instructions: 'Safe assistant', @@ -164,10 +177,10 @@ describe.each([ mockGetFiles.mockReset().mockResolvedValue([]); mockGetConvo.mockReset().mockResolvedValue(null); mockEncodeAndFormat.mockReset().mockResolvedValue({ files: [], image_urls: [] }); + mockGetChatProject.mockReset().mockResolvedValue(null); mockInitThread.mockReset(); mockCreateRun.mockReset(); mockRunAssistant.mockReset(); - mockCreateRunBody.mockReset(); /* Every run that reaches FINAL has persisted its assistant row: the controller publishes the conversation snapshot the client acknowledges, so a test that does not care about the write still needs one. Tests asserting on the stamp override this. */ @@ -230,6 +243,16 @@ describe.each([ }; }); + const useProject = (projectId, instructions) => { + req.body.endpointOption = { ...req.body.endpointOption, chatProjectId: projectId }; + mockGetChatProject.mockResolvedValue({ + _id: projectId, + instructions, + contextRevision: 1, + file_ids: [], + }); + }; + async function expectRawFreeRejection(rawContent, expectedSource, expectedField) { await chatController(req, res); @@ -322,8 +345,8 @@ describe.each([ it('releases a balance reservation that settles after thread initialization fails', async () => { req.config.filters = {}; const release = jest.fn().mockResolvedValue(undefined); - getBalanceConfig.mockReturnValue({ enabled: true }); - checkBalance.mockImplementation( + mockGetBalanceConfig.mockReturnValue({ enabled: true }); + mockCheckBalance.mockImplementation( () => new Promise((resolve) => setTimeout(() => resolve({ release }), 50)), ); mockInitThread.mockRejectedValueOnce(new Error('stop after initThread')); @@ -331,7 +354,7 @@ describe.each([ await chatController(req, res); expect(mockInitThread).toHaveBeenCalledTimes(1); - expect(checkBalance).toHaveBeenCalledTimes(1); + expect(mockCheckBalance).toHaveBeenCalledTimes(1); expect(release).toHaveBeenCalledTimes(1); }); @@ -362,6 +385,81 @@ describe.each([ expect(mockRetrieveAssistant).toHaveBeenCalledWith('asst-1'); expect(mockListThreadMessages).not.toHaveBeenCalled(); }); + it('blocks project guidance before provider, thread, message, run, or stream side effects', async () => { + req.config.filters = { + agentInstructions: { + pii: { + fields: ['instructions'], + starterPatterns: [], + customPatterns: [ + { + id: 'private', + label: 'private value', + regex: 'PRIVATE-[A-Z]+', + }, + ], + }, + }, + }; + useProject('507f1f77bcf86cd799439012', 'Project guidance PRIVATE-INSTRUCTION'); + + await expectRawFreeRejection('PRIVATE-INSTRUCTION', 'agent_instruction', 'instructions'); + + expect(mockGetChatProject).toHaveBeenCalledTimes(1); + expect(mockGetOpenAIClient).not.toHaveBeenCalled(); + expect(mockValidateAuthor).not.toHaveBeenCalled(); + }); + it('returns not found for an unavailable Project without provider or close-handler effects', async () => { + req.body.endpointOption = { chatProjectId: 'missing-project' }; + await chatController(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(mockGetOpenAIClient).not.toHaveBeenCalled(); + expect(mockInitThread).not.toHaveBeenCalled(); + expect(mockSaveUserMessage).not.toHaveBeenCalled(); + expect(mockCreateRun).not.toHaveBeenCalled(); + expect(mockRunAssistant).not.toHaveBeenCalled(); + expect(mockStreamRunManager).not.toHaveBeenCalled(); + await closeHandler(); + expect(mockHandleError).not.toHaveBeenCalled(); + expect(mockSendResponse).not.toHaveBeenCalled(); + }); + + it('passes authorized project guidance to the provider without loading project resources', async () => { + const project = { + _id: 'project-a', + instructions: 'Project instructions for the provider', + contextRevision: 4, + file_ids: ['unused-project-file'], + }; + req.body.endpoint = 'azureAssistants'; + req.body.endpointOption = { chatProjectId: 'project-a' }; + mockGetChatProject.mockResolvedValueOnce(project); + mockGetFiles.mockRejectedValueOnce(new Error('unused project file lookup must not run')); + mockInitThread.mockResolvedValueOnce({ thread_id: 'thread-existing' }); + mockGetOpenAIClient.mockResolvedValueOnce({ + openai: { + _options: { model: 'gpt-4' }, + responseMessage: { messageId: 'response-message' }, + beta: { + assistants: { retrieve: mockRetrieveAssistant }, + threads: { messages: { list: mockListThreadMessages }, runs: {} }, + }, + }, + }); + mockCreateRun.mockRejectedValueOnce(new Error('stop after provider request')); + + await chatController(req, res); + + expect(mockGetFiles).not.toHaveBeenCalled(); + expect(mockCreateRun).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + additional_instructions: expect.stringContaining(project.instructions), + }), + }), + ); + }); it('blocks paged historical user text before thread, message, run, or stream side effects', async () => { req.config.filters = { @@ -672,8 +770,8 @@ describe.each([ it('keeps the balance reservation until a run that continued in the background settles', async () => { req.config.filters = {}; const release = jest.fn().mockResolvedValue(undefined); - getBalanceConfig.mockReturnValue({ enabled: true }); - checkBalance.mockResolvedValue({ release }); + mockGetBalanceConfig.mockReturnValue({ enabled: true }); + mockCheckBalance.mockResolvedValue({ release }); mockInitThread.mockResolvedValueOnce({ thread_id: 'thread-existing' }); let finishBackgroundRun = () => undefined; const usage = { prompt_tokens: 1, completion_tokens: 1 }; @@ -795,4 +893,128 @@ describe.each([ }); }); } + it('rejects a new hosted run before provider execution when project guidance exceeds balance', async () => { + const projectContext = { + projectId: '507f1f77bcf86cd799439012', + contextRevision: 1, + instructions: 'Large project guidance '.repeat(100), + file_ids: [], + }; + const projectInstructions = `Project guidance (user-provided context; subordinate to system, platform, agent, and tool policies): +${projectContext.instructions}`; + req.body.thread_id = undefined; + req.body.conversationId = undefined; + useProject(projectContext.projectId, projectContext.instructions); + mockGetBalanceConfig.mockReturnValueOnce({ enabled: true }); + mockGetTransactions.mockResolvedValueOnce([]); + mockGetModelMaxTokens.mockReturnValueOnce(100000); + mockCountTokens.mockImplementationOnce(async (value) => value.length); + mockCheckBalance.mockRejectedValueOnce(new Error('Insufficient balance')); + mockInitThread.mockResolvedValueOnce({ thread_id: 'thread-new' }); + + await chatController(req, res); + + const promptText = `${req.body.text}\n\n${projectInstructions}`; + expect(mockCountTokens).toHaveBeenCalledWith(promptText); + expect(mockCheckBalance).toHaveBeenCalledWith( + expect.objectContaining({ + txData: expect.objectContaining({ + amount: promptText.length + 5 + 200, + }), + }), + expect.any(Object), + ); + expect(mockCreateRun).not.toHaveBeenCalled(); + expect(mockRunAssistant).not.toHaveBeenCalled(); + expect(mockStreamRunManager).not.toHaveBeenCalled(); + }); + it('includes new authorized project membership in the final conversation payload', async () => { + const projectContext = { + projectId: '507f1f77bcf86cd799439012', + contextRevision: 1, + instructions: '', + file_ids: [], + }; + req.body.endpoint = 'azureAssistants'; + req.body.thread_id = undefined; + useProject(projectContext.projectId, projectContext.instructions); + mockInitThread.mockResolvedValueOnce({ thread_id: 'thread-new' }); + mockCreateRun.mockResolvedValueOnce({ id: 'run-new', status: 'completed' }); + mockRunAssistant.mockResolvedValueOnce({ + run: { + id: 'run-new', + status: 'completed', + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }, + responseMessage: { messageId: 'assistant-message' }, + text: 'Done', + steps: [], + messages: [], + }); + mockGetOpenAIClient.mockResolvedValueOnce({ + openai: { + _options: { model: 'gpt-4' }, + responseMessage: { messageId: 'response-message' }, + beta: { + assistants: { retrieve: mockRetrieveAssistant }, + threads: { messages: { list: mockListThreadMessages }, runs: {} }, + }, + }, + }); + + await chatController(req, res); + + const finalEvent = mockSendEvent.mock.calls.find(([, event]) => event.final)?.[1]; + expect(finalEvent?.conversation).toEqual( + expect.objectContaining({ chatProjectId: projectContext.projectId }), + ); + }); + + it('uses persisted membership for an existing conversation in the final payload', async () => { + const existingConversation = { + conversationId: 'conversation-existing', + chatProjectId: '507f1f77bcf86cd799439013', + }; + req.body.endpoint = 'azureAssistants'; + req.body.conversationId = existingConversation.conversationId; + req.body.thread_id = 'thread-existing'; + mockGetConvo.mockResolvedValueOnce(existingConversation); + mockGetChatProject.mockResolvedValueOnce({ + _id: '507f1f77bcf86cd799439014', + contextRevision: 2, + instructions: '', + file_ids: [], + }); + mockInitThread.mockResolvedValueOnce({ thread_id: 'thread-existing' }); + mockCreateRun.mockResolvedValueOnce({ id: 'run-existing', status: 'completed' }); + mockRunAssistant.mockResolvedValueOnce({ + run: { + id: 'run-existing', + status: 'completed', + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }, + responseMessage: { messageId: 'assistant-message' }, + text: 'Done', + steps: [], + messages: [], + }); + mockGetOpenAIClient.mockResolvedValueOnce({ + openai: { + _options: { model: 'gpt-4' }, + responseMessage: { messageId: 'response-message' }, + beta: { + assistants: { retrieve: mockRetrieveAssistant }, + threads: { messages: { list: mockListThreadMessages }, runs: {} }, + }, + }, + }); + + await chatController(req, res); + + const finalEvent = mockSendEvent.mock.calls.find(([, event]) => event.final)?.[1]; + expect(finalEvent?.conversation).toEqual( + expect.objectContaining({ chatProjectId: existingConversation.chatProjectId }), + ); + expect(finalEvent?.conversation?.chatProjectId).not.toBe('507f1f77bcf86cd799439014'); + }); }); diff --git a/api/server/controllers/assistants/chatV1.js b/api/server/controllers/assistants/chatV1.js index 0f66c071547..95f0d66e2c3 100644 --- a/api/server/controllers/assistants/chatV1.js +++ b/api/server/controllers/assistants/chatV1.js @@ -17,6 +17,8 @@ const { reportLocatorTraversalFailure, preflightAssistantUserMessageContent, settleAssistantFinal, + resolveAssistantProjectTurn, + joinChatProjectInstructions, } = require('@librechat/api'); const { Time, @@ -55,7 +57,9 @@ const { reserveBalance, getMultiplier, getConvo, + getProjectFiles, getFiles, + getChatProject, } = require('~/models'); const { logViolation, getLogStores } = require('~/cache'); const { getOpenAIClient } = require('./helpers'); @@ -272,6 +276,25 @@ const chatV1 = async (req, res) => { await handleError(new Error('Request closed')); } }); + const projectTurn = await resolveAssistantProjectTurn( + { + userId: req.user.id, + tenantId: req.user.tenantId, + conversationId: convoId, + requestedProjectId: endpointOption?.chatProjectId ?? req.body?.chatProjectId, + resolvedConversation: req.resolvedConversation, + filters: req.config?.filters, + }, + { getConvo, getChatProject, getProjectFiles }, + ); + if (projectTurn.rejection) { + contentRejected = true; + return res.status(projectTurn.rejection.status).json(projectTurn.rejection.body); + } + const existingConversation = projectTurn.conversation; + const projectInstructions = projectTurn.instructions; + req.resolvedConversation = existingConversation; + req.chatProjectContext = projectTurn.context; if (convoId && !_thread_id) { completedRun = true; @@ -299,11 +322,14 @@ const chatV1 = async (req, res) => { transactions.reduce((acc, curr) => acc + curr.rawAmount, 0), ); - // TODO: make promptBuffer a config option; buffer for titles, needs buffer for system instructions + // TODO: make promptBuffer a config option; buffer for title generation. const promptBuffer = parentMessageId === Constants.NO_PARENT && !_thread_id ? 200 : 0; // 5 is added for labels - let promptTokens = (await countTokens(text + (promptPrefix ?? ''))) + 5; - promptTokens += totalPreviousTokens + promptBuffer; + const promptText = joinChatProjectInstructions( + `${text ?? ''}${promptPrefix ?? ''}`, + projectInstructions, + ); + let promptTokens = totalPreviousTokens + (await countTokens(promptText)) + 5 + promptBuffer; // Count tokens up to the current context window promptTokens = Math.min(promptTokens, getModelMaxTokens(model)); @@ -385,7 +411,7 @@ const chatV1 = async (req, res) => { const getRequestFileIds = async () => { let thread_file_ids = []; if (convoId) { - const convo = await getConvo(req.user.id, convoId); + const convo = existingConversation; if (convo && convo.file_ids) { thread_file_ids = convo.file_ids; } @@ -404,6 +430,12 @@ const chatV1 = async (req, res) => { } } }; + if (projectInstructions) { + body.additional_instructions = joinChatProjectInstructions( + body.additional_instructions, + projectInstructions, + ); + } const addVisionPrompt = async () => { if (!endpointOption.attachments) { @@ -534,6 +566,7 @@ const chatV1 = async (req, res) => { /* asynchronous */ userMessagePromise = saveUserMessage(req, { ...requestMessage, model }); + const conversationProjectId = projectTurn.membershipProjectId; conversation = { conversationId, endpoint, @@ -541,6 +574,7 @@ const chatV1 = async (req, res) => { instructions: instructions, assistant_id, // model, + ...(conversationProjectId !== undefined ? { chatProjectId: conversationProjectId } : {}), }; if (file_ids.length) { diff --git a/api/server/controllers/assistants/chatV2.js b/api/server/controllers/assistants/chatV2.js index d573b50b322..992dbfdfa9b 100644 --- a/api/server/controllers/assistants/chatV2.js +++ b/api/server/controllers/assistants/chatV2.js @@ -16,6 +16,8 @@ const { reportLocatorTraversalFailure, preflightAssistantUserMessageContent, settleAssistantFinal, + resolveAssistantProjectTurn, + joinChatProjectInstructions, } = require('@librechat/api'); const { Time, @@ -49,6 +51,8 @@ const { reserveBalance, renewBalanceReservation, releaseBalanceReservation, + getChatProject, + getProjectFiles, getFiles, } = require('~/models'); const { logViolation, getLogStores } = require('~/cache'); @@ -143,6 +147,25 @@ const chatV2 = async (req, res) => { await handleError(new Error('Request closed')); } }); + const projectTurn = await resolveAssistantProjectTurn( + { + userId: req.user.id, + tenantId: req.user.tenantId, + conversationId: convoId, + requestedProjectId: endpointOption?.chatProjectId ?? req.body?.chatProjectId, + resolvedConversation: req.resolvedConversation, + filters: req.config?.filters, + }, + { getConvo, getChatProject, getProjectFiles }, + ); + if (projectTurn.rejection) { + contentRejected = true; + return res.status(projectTurn.rejection.status).json(projectTurn.rejection.body); + } + const existingConversation = projectTurn.conversation; + const projectInstructions = projectTurn.instructions; + req.resolvedConversation = existingConversation; + req.chatProjectContext = projectTurn.context; if (convoId && !_thread_id) { completedRun = true; @@ -170,11 +193,14 @@ const chatV2 = async (req, res) => { transactions.reduce((acc, curr) => acc + curr.rawAmount, 0), ); - // TODO: make promptBuffer a config option; buffer for titles, needs buffer for system instructions + // TODO: make promptBuffer a config option; buffer for title generation. const promptBuffer = parentMessageId === Constants.NO_PARENT && !_thread_id ? 200 : 0; // 5 is added for labels - let promptTokens = (await countTokens(text + (promptPrefix ?? ''))) + 5; - promptTokens += totalPreviousTokens + promptBuffer; + const promptText = joinChatProjectInstructions( + `${text ?? ''}${promptPrefix ?? ''}`, + projectInstructions, + ); + let promptTokens = totalPreviousTokens + (await countTokens(promptText)) + 5 + promptBuffer; // Count tokens up to the current context window promptTokens = Math.min(promptTokens, getModelMaxTokens(model)); @@ -256,20 +282,17 @@ const chatV2 = async (req, res) => { endpointOption, clientTimestamp, }); - - let existingConversationPromise; - const getExistingConversation = () => { - if (!convoId) { - return Promise.resolve(null); - } - existingConversationPromise ??= getConvo(req.user.id, convoId); - return existingConversationPromise; - }; + if (projectInstructions) { + body.additional_instructions = joinChatProjectInstructions( + body.additional_instructions, + projectInstructions, + ); + } const getRequestFileIds = async () => { let thread_file_ids = []; if (convoId) { - const convo = await getExistingConversation(); + const convo = existingConversation; if (convo && convo.file_ids) { thread_file_ids = convo.file_ids; } @@ -364,6 +387,7 @@ const chatV2 = async (req, res) => { /* asynchronous */ userMessagePromise = saveUserMessage(req, { ...requestMessage, model }); + const conversationProjectId = projectTurn.membershipProjectId; conversation = { conversationId, endpoint, @@ -371,6 +395,7 @@ const chatV2 = async (req, res) => { instructions: instructions, assistant_id, // model, + ...(conversationProjectId !== undefined ? { chatProjectId: conversationProjectId } : {}), }; if (file_ids.length) { diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 338d434de7a..557cdecce93 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -104,6 +104,7 @@ afterEach(() => { delete process.env.SAML_SESSION_SECRET; delete process.env.ALLOW_ACCOUNT_DELETION; delete process.env.ALLOW_EMAIL_CHANGE; + delete process.env.RAG_API_URL; delete process.env.ADMIN_PANEL_URL; delete process.env.ENABLE_INSIGHTS; delete process.env.ANALYTICS_GTM_ID; @@ -357,6 +358,26 @@ describe('GET /api/config', () => { }); describe('authenticated (req.user exists)', () => { + it('exposes retrieval availability only after login without exposing the service URL', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + const app = createApp(mockUser); + delete process.env.RAG_API_URL; + + const disabled = await request(app).get('/api/config'); + expect(disabled.statusCode).toBe(200); + expect(disabled.body.ragEnabled).toBe(false); + + process.env.RAG_API_URL = 'http://rag.internal:8000'; + const enabled = await request(app).get('/api/config'); + expect(enabled.statusCode).toBe(200); + expect(enabled.body.ragEnabled).toBe(true); + expect(JSON.stringify(enabled.body)).not.toContain(process.env.RAG_API_URL); + + const anonymous = await request(createApp()).get('/api/config'); + expect(anonymous.statusCode).toBe(200); + expect(anonymous.body).not.toHaveProperty('ragEnabled'); + }); + it('should call getAppConfig with role, userId, and tenantId', async () => { mockGetAppConfig.mockResolvedValue(baseAppConfig); mockGetTenantId.mockReturnValue('fallback-tenant'); diff --git a/api/server/routes/__tests__/projects.spec.js b/api/server/routes/__tests__/projects.spec.js new file mode 100644 index 00000000000..9137ba190e8 --- /dev/null +++ b/api/server/routes/__tests__/projects.spec.js @@ -0,0 +1,151 @@ +const express = require('express'); +const request = require('supertest'); + +const resolvedConfig = { + projects: { + maxFiles: 3, + maxInstructionsLength: 120, + maxDescriptionLength: 80, + }, +}; +const mockHandler = jest.fn((req, res) => { + res.status(200).json({ config: req.config }); +}); +const mockCreateProjectHandlers = jest.fn(() => ({ + listProjects: mockHandler, + createProject: mockHandler, + assignConversationToProject: mockHandler, + getProject: mockHandler, + updateProject: mockHandler, + deleteProject: mockHandler, + listProjectFiles: mockHandler, + listAvailableProjectFiles: mockHandler, + addProjectFile: mockHandler, + removeProjectFile: mockHandler, +})); +const mockRequireJwtAuth = jest.fn((req, _res, next) => { + req.user = { id: 'project-user', tenantId: 'tenant-a' }; + next(); +}); +const mockConfigMiddleware = jest.fn((req, _res, next) => { + req.config = resolvedConfig; + next(); +}); + +jest.mock('@librechat/api', () => ({ createProjectHandlers: mockCreateProjectHandlers })); +jest.mock('~/models', () => ({ + listChatProjects: jest.fn(), + createChatProject: jest.fn(), + getChatProject: jest.fn(), + updateChatProject: jest.fn(), + deleteChatProject: jest.fn(), + assignConversationToProject: jest.fn(), + addChatProjectFile: jest.fn(), + removeChatProjectFile: jest.fn(), + getProjectFiles: jest.fn(), + getAvailableProjectFiles: jest.fn(), +})); +jest.mock('~/server/middleware', () => ({ + requireJwtAuth: mockRequireJwtAuth, + configMiddleware: mockConfigMiddleware, +})); + +const projectsRouter = require('../projects'); + +const app = express(); +app.use(express.json()); +app.use('/api/projects', projectsRouter); + +const routeCases = [ + { name: 'list projects', method: 'get', path: '/api/projects', resolvesConfig: false }, + { name: 'create projects', method: 'post', path: '/api/projects', resolvesConfig: true }, + { + name: 'assign conversations', + method: 'put', + path: '/api/projects/conversations/conversation-id', + resolvesConfig: false, + }, + { + name: 'list available project files', + method: 'get', + path: '/api/projects/project-id/files/available', + resolvesConfig: false, + }, + { + name: 'list project files', + method: 'get', + path: '/api/projects/project-id/files', + resolvesConfig: false, + }, + { + name: 'add project files', + method: 'post', + path: '/api/projects/project-id/files', + resolvesConfig: true, + }, + { + name: 'remove project files', + method: 'delete', + path: '/api/projects/project-id/files/file-id', + resolvesConfig: false, + }, + { + name: 'get projects', + method: 'get', + path: '/api/projects/project-id', + resolvesConfig: false, + }, + { + name: 'update projects', + method: 'patch', + path: '/api/projects/project-id', + resolvesConfig: true, + }, + { + name: 'delete projects', + method: 'delete', + path: '/api/projects/project-id', + resolvesConfig: false, + }, +]; + +describe('Projects route middleware', () => { + beforeEach(() => { + mockRequireJwtAuth.mockClear(); + mockConfigMiddleware.mockClear(); + mockHandler.mockClear(); + }); + + it.each(routeCases)( + '$name resolves config only when required', + async ({ method, path, resolvesConfig }) => { + const response = await request(app)[method](path); + + expect(response.status).toBe(200); + expect(mockRequireJwtAuth).toHaveBeenCalledTimes(1); + expect(mockHandler).toHaveBeenCalledTimes(1); + expect(mockHandler.mock.calls[0][0].config).toEqual( + resolvesConfig ? resolvedConfig : undefined, + ); + + if (resolvesConfig) { + expect(mockConfigMiddleware).toHaveBeenCalledTimes(1); + expect(mockConfigMiddleware.mock.calls[0][0].user).toEqual({ + id: 'project-user', + tenantId: 'tenant-a', + }); + expect(mockRequireJwtAuth.mock.invocationCallOrder[0]).toBeLessThan( + mockConfigMiddleware.mock.invocationCallOrder[0], + ); + expect(mockConfigMiddleware.mock.invocationCallOrder[0]).toBeLessThan( + mockHandler.mock.invocationCallOrder[0], + ); + } else { + expect(mockConfigMiddleware).not.toHaveBeenCalled(); + expect(mockRequireJwtAuth.mock.invocationCallOrder[0]).toBeLessThan( + mockHandler.mock.invocationCallOrder[0], + ); + } + }, + ); +}); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 20724ca9dbe..6a585bfb2dc 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -157,6 +157,7 @@ function buildPostLoginPayload(appConfig) { sharedLinksEnabled, publicSharedLinksEnabled, openidReuseTokens, + ragEnabled: Boolean(process.env.RAG_API_URL?.trim()), /** Read inline (not module-level) for per-request evaluation and test isolation */ allowAccountDeletion: process.env.ALLOW_ACCOUNT_DELETION === undefined || @@ -304,6 +305,7 @@ router.get('/', async function (req, res) { conversationListLimits, sharedLinksSnapshotFilesEnabled: sharedLinksEnabled && isFileSnapshotEnabled(appConfig), socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins, + projects: appConfig?.projects, interface: appConfig?.interfaceConfig, titleGenerationTiming: resolveTitleTiming({ appConfig, diff --git a/api/server/routes/projects.js b/api/server/routes/projects.js index fd782a1ba5d..8c0414fe625 100644 --- a/api/server/routes/projects.js +++ b/api/server/routes/projects.js @@ -1,6 +1,6 @@ const express = require('express'); const { createProjectHandlers } = require('@librechat/api'); -const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); +const { configMiddleware, requireJwtAuth } = require('~/server/middleware'); const db = require('~/models'); const router = express.Router(); @@ -11,15 +11,21 @@ const handlers = createProjectHandlers({ updateChatProject: db.updateChatProject, deleteChatProject: db.deleteChatProject, assignConversationToProject: db.assignConversationToProject, + addChatProjectFile: db.addChatProjectFile, + removeChatProjectFile: db.removeChatProjectFile, + getProjectFiles: db.getProjectFiles, + getAvailableProjectFiles: db.getAvailableProjectFiles, }); - router.use(requireJwtAuth); - router.get('/', handlers.listProjects); -router.post('/', handlers.createProject); +router.post('/', configMiddleware, handlers.createProject); router.put('/conversations/:conversationId', handlers.assignConversationToProject); +router.get('/:projectId/files/available', handlers.listAvailableProjectFiles); +router.get('/:projectId/files', handlers.listProjectFiles); +router.post('/:projectId/files', configMiddleware, handlers.addProjectFile); +router.delete('/:projectId/files/:fileId', handlers.removeProjectFile); router.get('/:projectId', handlers.getProject); -router.patch('/:projectId', handlers.updateProject); +router.patch('/:projectId', configMiddleware, handlers.updateProject); router.delete('/:projectId', handlers.deleteProject); module.exports = router; diff --git a/api/server/services/Endpoints/agents/addedConvo.js b/api/server/services/Endpoints/agents/addedConvo.js index 4fed5795688..b22c3b5b0b3 100644 --- a/api/server/services/Endpoints/agents/addedConvo.js +++ b/api/server/services/Endpoints/agents/addedConvo.js @@ -205,6 +205,7 @@ const processAddedConvo = async ({ signal, }, { + getProjectFiles: db.getProjectFiles, getFiles: db.getFiles, getUserKey: db.getUserKey, getMessages: db.getMessages, diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 94dc2e5b339..40d2002b565 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -6,6 +6,7 @@ const { resolveAdmittedCodeEnvironmentDecision, createConcurrencyLimiter, loadSkillStates, + resolveInitializationProjectContext, initializeAgent, primeInvokedSkillsForProfiles, validateAgentModel, @@ -23,9 +24,9 @@ const { collectCodeExecutionProfileRoutes, getLazySubagentConfigId, resolveCodeExecutionContext, - resolveCodeExecutionWorkspaceContext, optsOutOfAttachedCodeEnvironment, isImplicitStatefulCodeRouteAvailable, + resolveCodeExecutionWorkspaceContext, createStatefulCodeEnvironmentPolicyError, buildSubagentThreadTaskConfig, backgroundCompletionWakeupsEnabled, @@ -586,6 +587,15 @@ const initializeClientWithProvider = async ({ /** @type {Array} */ const usageEmitSink = []; + const chatProjectContextPromise = resolveInitializationProjectContext( + { req, endpointOption, conversationId, conversationPromise: requestConversationPromise }, + { + getConvo: db.getConvo, + getChatProject: db.getChatProject, + getProjectFiles: db.getProjectFiles, + }, + ); + const [ memoryAvailable, accessibleSkillIds, @@ -594,6 +604,7 @@ const initializeClientWithProvider = async ({ { skillStates, defaultActiveOnShare }, { primaryAgent, modelsConfig }, requestConversation, + chatProjectContext, toolRoleGrants, ] = await Promise.all([ memoryAvailablePromise, @@ -603,10 +614,13 @@ const initializeClientWithProvider = async ({ skillStatesPromise, validatedPrimaryAgentPromise, requestConversationPromise, + chatProjectContextPromise, toolRoleGrantsPromise, ]); /** Preserve the owner-scoped fallback for loaders that share this request. */ req.resolvedConversation = requestConversation; + req.chatProjectContext = chatProjectContext; + req.chatProjectContextEnabled = true; const { decision: codeEnvironmentDecision, conversation: admittedConversation } = await resolveAdmittedCodeEnvironmentDecision({ appConfig, @@ -699,7 +713,6 @@ const initializeClientWithProvider = async ({ primaryAgent.skills = resolvedSkillIds.map((id) => id.toString()); } } - const primaryScopedSkillIds = resolveAgentScopedSkillIds({ agent: primaryAgent, accessibleSkillIds, @@ -719,9 +732,9 @@ const initializeClientWithProvider = async ({ skillsCapabilityEnabled, ephemeralSkillsToggle, }); - const primaryConfig = await initializeAgent( { + useChatProjectContext: true, req, res, loadTools, @@ -748,6 +761,7 @@ const initializeClientWithProvider = async ({ signal, }, { + getProjectFiles: db.getProjectFiles, getFiles: db.getFiles, getUserKey: db.getUserKey, getMessages: db.getMessages, @@ -840,6 +854,7 @@ const initializeClientWithProvider = async ({ checkPermission, logViolation, db: { + getProjectFiles: db.getProjectFiles, getFiles: db.getFiles, getUserKey: db.getUserKey, getMessages: db.getMessages, @@ -1325,6 +1340,7 @@ const initializeClientWithProvider = async ({ signal: context.signal, }, { + getProjectFiles: db.getProjectFiles, getFiles: db.getFiles, getUserKey: db.getUserKey, getMessages: db.getMessages, diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index b68966d1b8b..43a94d69143 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -771,6 +771,48 @@ describe('initializeClient — processAgent ACL gate', () => { canCreateSkillSpy.mockRestore(); } }); + it('starts Project lookup as soon as the conversation snapshot resolves', async () => { + const models = deferred(); + const conversation = deferred(); + const project = deferred(); + const req = makeReq(); + delete req.resolvedConversation; + const getConvoSpy = jest.spyOn(db, 'getConvo').mockReturnValue(conversation.promise); + const getChatProjectSpy = jest.spyOn(db, 'getChatProject').mockReturnValue(project.promise); + getModelsConfig.mockReturnValueOnce(models.promise); + mockInitializeAgent.mockResolvedValue(makePrimaryConfig([])); + + try { + const initialization = initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + conversation.resolve({ + conversationId: 'conv_1', + chatProjectId: 'project-1', + user: req.user.id, + tenantId: null, + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(getChatProjectSpy).toHaveBeenCalledWith(req.user.id, 'project-1'); + + models.resolve({}); + project.resolve({ + _id: 'project-1', + instructions: '', + contextRevision: 0, + file_ids: [], + tenantId: null, + }); + await initialization; + } finally { + getConvoSpy.mockRestore(); + getChatProjectSpy.mockRestore(); + } + }); it('resolves model-spec skill names through deployment-aware skill methods', async () => { const deploymentSkillId = new mongoose.Types.ObjectId(); diff --git a/api/server/services/Threads/manage.js b/api/server/services/Threads/manage.js index c6dd17b065b..2400c726635 100644 --- a/api/server/services/Threads/manage.js +++ b/api/server/services/Threads/manage.js @@ -82,6 +82,9 @@ async function saveUserMessage(req, params) { tokenCount, }; + // Only confirmed new conversations seed membership; saveConvo applies it on insert. + const chatProjectId = + req?.resolvedConversation === null ? req.chatProjectContext?.projectId : undefined; const convo = { endpoint: params.endpoint, conversationId: params.conversationId, @@ -89,8 +92,8 @@ async function saveUserMessage(req, params) { instructions: params.instructions, assistant_id: params.assistant_id, model: params.model, + ...(typeof chatProjectId === 'string' && chatProjectId ? { chatProjectId } : {}), }; - if (params.files?.length) { userMessage.files = params.files.map(({ file_id }) => ({ file_id })); convo.file_ids = params.file_ids; diff --git a/api/server/services/Threads/manage.retention.spec.js b/api/server/services/Threads/manage.retention.spec.js index 60e0ee09cfc..c3469ecb4b4 100644 --- a/api/server/services/Threads/manage.retention.spec.js +++ b/api/server/services/Threads/manage.retention.spec.js @@ -9,7 +9,8 @@ jest.mock('@librechat/api', () => ({ jest.mock('~/server/services/Files/process', () => ({ retrieveAndProcessFile: jest.fn() })); -const { Message, Conversation } = require('~/db/models'); +const { Message, Conversation, ChatProject } = require('~/db/models'); +const { createChatProject, assignConversationToProject } = require('~/models'); const { saveUserMessage, saveAssistantMessage, checkMessageGaps } = require('./manage'); describe('Assistants message retention', () => { @@ -25,6 +26,43 @@ describe('Assistants message retention', () => { await mongoServer.stop(); }); + it('seeds Project membership once and preserves a concurrent move without revalidation', async () => { + const user = new mongoose.Types.ObjectId().toString(); + const conversationId = v4(); + const firstProject = await createChatProject(user, { name: 'Original project' }); + const nextProject = await createChatProject(user, { name: 'Moved project' }); + const firstProjectId = firstProject._id.toString(); + const nextProjectId = nextProject._id.toString(); + const req = { + user: { id: user }, + body: { conversationId }, + resolvedConversation: null, + chatProjectContext: { projectId: firstProjectId }, + config: {}, + }; + const params = { + user, + conversationId, + endpoint: 'assistants', + assistant_id: 'asst_project', + thread_id: 'thread_project', + text: 'Continue with the authorized context.', + }; + await saveUserMessage(req, { ...params, messageId: v4() }); + expect(req.resolvedConversation.chatProjectId).toBe(firstProjectId); + + await assignConversationToProject(user, conversationId, nextProjectId); + const projectLookup = jest.spyOn(ChatProject, 'exists'); + try { + await saveUserMessage(req, { ...params, messageId: v4() }); + const saved = await Conversation.findOne({ user, conversationId }).lean(); + expect(saved.chatProjectId).toBe(nextProjectId); + expect(projectLookup).not.toHaveBeenCalled(); + } finally { + projectLookup.mockRestore(); + } + }); + it.each([false, true])( 'retains the full turn and gap sync with isTemporary=%s', async (isTemporary) => { diff --git a/api/server/services/__tests__/toolCapabilityGates.spec.js b/api/server/services/__tests__/toolCapabilityGates.spec.js index 56820fec4b4..1b8693ddc28 100644 --- a/api/server/services/__tests__/toolCapabilityGates.spec.js +++ b/api/server/services/__tests__/toolCapabilityGates.spec.js @@ -47,12 +47,15 @@ const CLASSIFIED = { * comment. */ 'packages/api/src/agents/openai/service.ts': 3, + /** Two gates for the project file corpus (`addProjectFilesToFileSearch` and + * `canUseProjectFileSearch`), each paired with the `fileSearchAvailable` + * grant resolved by the caller, plus two `recordCapabilityToolNames` + * bookkeeping reads that describe what was built. */ + 'packages/api/src/agents/initialize.ts': 4, + // -- Not gates --------------------------------------------------------------- /** The upload-resource map entry, and one mention in a doc comment. */ 'packages/api/src/tools/rolePermissions.ts': 2, - /** `recordCapabilityToolNames` bookkeeping — describes what was built, does - * not decide whether to build it. */ - 'packages/api/src/agents/initialize.ts': 2, /** Default capability list shipped in config. */ 'packages/data-provider/src/config.ts': 2, /** Config normalization only: strips stale `tool_options` when the capability diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index 9174e14694e..7cddaa218ec 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -28,6 +28,7 @@ import { QueuedTurnPortalProvider } from './Steering/QueuedTurnPortal'; import ApprovalProvider from './Messages/Content/ApprovalContext'; import ConversationStarters from './Input/ConversationStarters'; import { pendingApprovalActionFamily } from './approval/state'; +import ProjectBadge from '~/components/Projects/ProjectBadge'; import { composerLiftFamily } from './Input/Composer/state'; import { showComposerTipsAtom } from '~/store/composerTips'; import { useGetMessagesByConvoId } from '~/data-provider'; @@ -113,6 +114,7 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje ? chatHelpers.conversation : undefined; const activeSubagentThread = activeConversation?.subagentThread; + const chatProjectId = activeConversation?.chatProjectId || undefined; useAdaptiveSSE(rootSubmission, chatHelpers, false, index); @@ -190,6 +192,9 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje readOnly={isSubagentThreadReadOnly} /> + {!isLandingPage && chatProjectId && ( + + )} <>
{content} diff --git a/client/src/components/Chat/__tests__/ChatView.spec.tsx b/client/src/components/Chat/__tests__/ChatView.spec.tsx index 1be3ca02a84..e8d4d94b8a1 100644 --- a/client/src/components/Chat/__tests__/ChatView.spec.tsx +++ b/client/src/components/Chat/__tests__/ChatView.spec.tsx @@ -20,6 +20,7 @@ jest.mock('~/hooks/AuthContext', () => ({ jest.mock('~/data-provider', () => ({ useGetMessagesByConvoId: () => ({ data: null, isLoading: false, isFetching: false }), + useProjectQuery: () => ({ data: undefined }), })); /** @@ -72,12 +73,6 @@ describe('ChatView page heading', () => { expect(headings).toHaveLength(1); }); - test('keeps the heading visually hidden', () => { - render(); - - expect(screen.getByRole('heading', { level: 1 })).toHaveClass('sr-only'); - }); - test('announces a localized new chat heading on the landing page', () => { render(); diff --git a/client/src/components/Chat/__tests__/ChatView.subagent.spec.tsx b/client/src/components/Chat/__tests__/ChatView.subagent.spec.tsx index f8527fa1d24..93d1073599b 100644 --- a/client/src/components/Chat/__tests__/ChatView.subagent.spec.tsx +++ b/client/src/components/Chat/__tests__/ChatView.subagent.spec.tsx @@ -55,6 +55,7 @@ jest.mock('~/Providers', () => { }); jest.mock('~/data-provider', () => ({ + useProjectQuery: () => ({ data: undefined }), useGetMessagesByConvoId: () => ({ data: [{ messageId: 'message-1' }], isLoading: false, @@ -114,20 +115,4 @@ describe('ChatView child-thread execution identity', () => { expect(screen.getByRole('note')).toHaveTextContent('com_ui_subagent_thread_read_only'); expect(screen.getByTestId('header')).toHaveAttribute('data-read-only', 'true'); }); - - it('keeps a saved-agent child view-only after it settles', () => { - mockConversation = { - conversationId: 'child-thread', - title: 'Saved agent child', - subagentThread: { - parentConversationId: 'parent-thread', - }, - }; - - render(); - - expect(screen.queryByTestId('chat-form')).not.toBeInTheDocument(); - expect(screen.getByRole('note')).toHaveTextContent('com_ui_subagent_thread_read_only'); - expect(screen.getByTestId('header')).toHaveAttribute('data-read-only', 'true'); - }); }); diff --git a/client/src/components/Conversations/ProjectsSection.tsx b/client/src/components/Conversations/ProjectsSection.tsx index 108bde61fd0..8c7618dfd22 100644 --- a/client/src/components/Conversations/ProjectsSection.tsx +++ b/client/src/components/Conversations/ProjectsSection.tsx @@ -41,7 +41,6 @@ import { } from '~/data-provider'; import ProjectCreateDialog from '~/components/Projects/ProjectCreateDialog'; import ProjectDeleteDialog from '~/components/Projects/ProjectDeleteDialog'; -import ProjectEditDialog from '~/components/Projects/ProjectEditDialog'; import { useLocalize, useLocalStorage, useNewConvo } from '~/hooks'; import { clearMessagesCache, cn, rowActionClasses } from '~/utils'; import { Collapse } from '~/components/ui'; @@ -188,8 +187,8 @@ const ProjectItem = memo( const menuId = useId(); const [expanded, setExpanded] = useState(defaultExpanded); const [isMenuOpen, setIsMenuOpen] = useState(false); - const [isRenameOpen, setIsRenameOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const deleteMenuRef = useRef(null); const projectChatPath = `/c/${Constants.NEW_CONVO}?projectId=${encodeURIComponent(project._id)}`; /* The whole item, header plus its expanded chats, is the drop target for @@ -272,23 +271,29 @@ const ProjectItem = memo( id: `${menuId}-rename`, label: localize('com_ui_edit_project'), icon: