Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions api/server/controllers/agents/__tests__/callbacks.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ jest.mock('@librechat/api', () => ({
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
info: jest.fn(),
error: jest.fn(),
},
}));
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
102 changes: 98 additions & 4 deletions api/server/controllers/agents/__tests__/openai.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
Expand All @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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',
Expand All @@ -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');
Expand All @@ -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: {
Expand All @@ -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');
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading