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
2 changes: 2 additions & 0 deletions api/server/routes/__test-utils__/convos-route-mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ module.exports = {
),
createBackgroundTaskPolicyMiddleware: jest.fn(() => (_req, _res, next) => next()),
createGitHubPullRequestSource: jest.fn(() => ({ find: jest.fn() })),
createProxyAwareFetch: jest.fn(() => jest.fn()),
createPullRequestLookup: jest.fn(() => jest.fn()),
createConversationPullRequestsHandler: jest.fn(() => jest.fn()),
createConversationPullRequestHandler: jest.fn(
() => (_req, res) => res.status(200).json({ pullRequest: null }),
),
Expand Down
18 changes: 18 additions & 0 deletions api/server/routes/__tests__/config.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ describe('GET /api/config', () => {
expect(response.body).not.toHaveProperty('conversationImportMaxFileSize');
expect(response.body).not.toHaveProperty('insightsEnabled');
expect(response.body).not.toHaveProperty('pullRequestsEnabled');
expect(response.body).not.toHaveProperty('pullRequestsBatchVersion');
expect(response.body).not.toHaveProperty('mcpApps');
});

Expand Down Expand Up @@ -683,9 +684,26 @@ describe('GET /api/config', () => {
});
const response = await request(createApp(mockUser)).get('/api/config');
expect(response.body.pullRequestsEnabled).toBe(expected);
/** The batch route's version rides with the flag, so a client never sees one without the other. */
if (expected) {
expect(response.body.pullRequestsBatchVersion).toBe(1);
expect(response.body.pullRequestsMaxConcurrentLookups).toBe(4);
} else {
expect(response.body).not.toHaveProperty('pullRequestsBatchVersion');
expect(response.body).not.toHaveProperty('pullRequestsMaxConcurrentLookups');
}
},
);

it('advertises the configured lookup limit so a fallback client can keep to it', async () => {
mockGetAppConfig.mockResolvedValue({
...baseAppConfig,
endpoints: { agents: { pullRequests: { enabled: true, maxConcurrentLookups: 2 } } },
});
const response = await request(createApp(mockUser)).get('/api/config');
expect(response.body.pullRequestsMaxConcurrentLookups).toBe(2);
});

it('should not advertise pull requests for a config with no endpoints', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
const response = await request(createApp(mockUser)).get('/api/config');
Expand Down
4 changes: 2 additions & 2 deletions api/server/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const {
resolveCodeWorkspaceInheritanceCapability,
resolveCodeEnvironmentTransitionVersion,
loadConversationListLimits,
resolvePullRequestCapabilities,
} = require('@librechat/api');
const {
DEFAULT_MCP_APP_CSP_LIMITS,
Expand Down Expand Up @@ -331,8 +332,7 @@ router.get('/', async function (req, res) {
langfuseConnectionAccess,
insightsEnabled: isEnabled(process.env.ENABLE_INSIGHTS),
/** Lets the client skip the pull request lookup entirely when the feature is off. */
pullRequestsEnabled:
appConfig?.endpoints?.[EModelEndpoint.agents]?.pullRequests?.enabled === true,
...resolvePullRequestCapabilities(appConfig),
compactionEnabled: appConfig?.summarization?.enabled !== false,
...(codeEnvironmentDecisionVersion != null ? { codeEnvironmentDecisionVersion } : {}),
mcpApps: resolveMCPAppsPolicy(
Expand Down
15 changes: 14 additions & 1 deletion api/server/routes/convos.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ const {
createBackgroundTaskPolicyMiddleware,
createConversationPullRequestHandler,
createGitHubPullRequestSource,
createProxyAwareFetch,
createPullRequestLookup,
createConversationPullRequestsHandler,
backgroundTaskRegistry,
createSubagentThreadViewHandler,
createGeneratedTitleHandler,
Expand Down Expand Up @@ -170,10 +172,20 @@ const backgroundTaskIndexHandler = createBackgroundTaskIndexHandler({
const backgroundTaskCancelHandler = createBackgroundTaskCancelHandler({
registry: backgroundTaskRegistry,
});
/** One lookup, so the header's single route and the sidebar's batch route share its cache. */
const pullRequestLookup = createPullRequestLookup({
source: createGitHubPullRequestSource({ fetchFn: createProxyAwareFetch() }),
});
const conversationPullRequestHandler = createConversationPullRequestHandler({
getConvoLaneGit: db.getConvoLaneGit,
getAppConfig,
lookup: createPullRequestLookup({ source: createGitHubPullRequestSource({ fetchFn: fetch }) }),
lookup: pullRequestLookup,
env: process.env,
});
const conversationPullRequestsHandler = createConversationPullRequestsHandler({
getConvosLaneGit: db.getConvosLaneGit,
getAppConfig,
lookup: pullRequestLookup,
env: process.env,
});
router.use(requireJwtAuth);
Expand Down Expand Up @@ -248,6 +260,7 @@ router.post(
subagentControlHandler,
);
router.get('/:parentConversationId/subagents', parentSubagentIndexHandler);
router.post('/pull-requests', conversationPullRequestsHandler);
router.get('/:conversationId/pull-request', conversationPullRequestHandler);
router.get('/:conversationId/background-tasks', backgroundTaskPolicy, backgroundTaskIndexHandler);
router.post(
Expand Down
1 change: 1 addition & 0 deletions api/server/services/ToolService.js
Original file line number Diff line number Diff line change
Expand Up @@ -2376,6 +2376,7 @@ async function loadToolsForExecution({
environmentId: codeExecutionContext.codeWorkspace.environmentId,
workspaceId: codeExecutionContext.codeWorkspace.workspaceId,
},
admittedEpoch: req.resolvedConversation?.codeAttachmentEpoch,
getConvoLaneContext,
reserveConvoLaneGitSeq,
setConvoLaneGit,
Expand Down
75 changes: 57 additions & 18 deletions api/server/services/__tests__/ToolService.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3236,15 +3236,9 @@ describe('ToolService - Action Capability Gating', () => {
codeWorkspaces: [{ environmentId: 'personal-machine', workspaceId: 'project-a' }],
};
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
mockResolveCodeExecutionContext.mockReturnValueOnce({
baseUrl: 'http://attached-code.test/v1',
codeSessionKey: 'execute_code:stateful:attached',
executionProfile: 'stateful',
statefulSessions: true,
environmentType: 'attached',
environmentId: 'personal-machine',
bridgeWorkerId: 'worker-abc',
});
req.config.endpoints[EModelEndpoint.agents].statefulCodeSessions = {
environments: [attachedEnvironment()],
};
mockCreateLaneGitRecorder.mockClear();

await loadToolsForExecution({
Expand All @@ -3255,6 +3249,7 @@ describe('ToolService - Action Capability Gating', () => {
tools: [Tools.execute_code],
stateful_code_sessions: true,
stateful_code_environment: 'agent-user',
code_environment_id: 'personal-machine',
},
conversationId: 'resolved-convo',
toolNames: [AgentConstants.BASH_TOOL],
Expand All @@ -3279,6 +3274,55 @@ describe('ToolService - Action Capability Gating', () => {
expect(mockSetConvoLaneGit).toHaveBeenCalledWith(input);
});

it.each([
['a moved conversation', 3, 3],
['a conversation never moved', 0, 0],
['a conversation whose admitted read carried no epoch', undefined, undefined],
])(
'fences the lane report by the epoch of the admitted decision for %s',
async (_label, admitted, expected) => {
const capabilities = [
AgentCapabilities.tools,
AgentCapabilities.execute_code,
AgentCapabilities.stateful_code_sessions,
];
const req = createMockReq(capabilities);
req.config.endpoints[EModelEndpoint.agents].pullRequests = { enabled: true };
req.resolvedConversation = {
conversationId: 'resolved-convo',
codeWorkspaces: [{ environmentId: 'personal-machine', workspaceId: 'project-a' }],
...(admitted === undefined ? {} : { codeAttachmentEpoch: admitted }),
};
req.body = {
conversationId: 'resolved-convo',
codeWorkspaces: [{ environmentId: 'personal-machine', workspaceId: 'project-a' }],
};
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
req.config.endpoints[EModelEndpoint.agents].statefulCodeSessions = {
environments: [attachedEnvironment()],
};
mockCreateLaneGitRecorder.mockClear();

await loadToolsForExecution({
req,
res: {},
agent: {
id: 'attached-agent',
tools: [Tools.execute_code],
stateful_code_sessions: true,
stateful_code_environment: 'agent-user',
code_environment_id: 'personal-machine',
},
conversationId: 'resolved-convo',
toolNames: [AgentConstants.BASH_TOOL],
toolRegistry: new Map([[AgentConstants.BASH_TOOL, { name: AgentConstants.BASH_TOOL }]]),
actionsEnabled: false,
});

expect(mockCreateLaneGitRecorder.mock.calls[0][0].admittedEpoch).toBe(expected);
},
);

it.each([
['unset', undefined],
['disabled', { enabled: false }],
Expand All @@ -3294,15 +3338,9 @@ describe('ToolService - Action Capability Gating', () => {
codeWorkspaces: [{ environmentId: 'personal-machine', workspaceId: 'project-a' }],
};
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
mockResolveCodeExecutionContext.mockReturnValueOnce({
baseUrl: 'http://attached-code.test/v1',
codeSessionKey: 'execute_code:stateful:attached',
executionProfile: 'stateful',
statefulSessions: true,
environmentType: 'attached',
environmentId: 'personal-machine',
bridgeWorkerId: 'worker-abc',
});
req.config.endpoints[EModelEndpoint.agents].statefulCodeSessions = {
environments: [attachedEnvironment()],
};
mockCreateLaneGitRecorder.mockClear();

await loadToolsForExecution({
Expand All @@ -3313,6 +3351,7 @@ describe('ToolService - Action Capability Gating', () => {
tools: [Tools.execute_code],
stateful_code_sessions: true,
stateful_code_environment: 'agent-user',
code_environment_id: 'personal-machine',
},
conversationId: 'resolved-convo',
toolNames: [AgentConstants.BASH_TOOL],
Expand Down
47 changes: 8 additions & 39 deletions client/src/components/Chat/PullRequest/Chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,10 @@ import { useAgentsMapContext, useChatContext } from '~/Providers';
import { TONE_DOT_CLASS, presentPullRequest } from './status';
import { URLIcon } from '~/components/Endpoints/URLIcon';
import { summarizePullRequest } from './summary';
import PullRequestPanel from './Panel';
import { useLocalize } from '~/hooks';
import PullRequestIcon from './Icon';
import PullRequestCard from './Card';
import { cn } from '~/utils';

/** Slides in from the chip's side, left to right, and fades; reduced motion only fades. */
const cardClass = cn(
'border-border-light bg-surface-secondary text-text-primary z-[200] w-80 max-w-[calc(100vw-2rem)] rounded-xl border shadow-lg focus:outline-none',
'origin-left -translate-x-3 opacity-0 transition duration-200 ease-out',
'data-[enter]:translate-x-0 data-[enter]:opacity-100',
'data-[leave]:-translate-x-3 data-[leave]:opacity-0',
'motion-reduce:translate-x-0 motion-reduce:transition-opacity',
);

function CiDot({ dotClass }: { dotClass: string }) {
return (
<span
data-testid="pull-request-ci-dot"
className={cn(
'ring-presentation absolute -right-0.5 -bottom-0.5 size-2 rounded-full ring-2',
dotClass,
)}
/>
);
}
import CiDot from './CiDot';

/** The agent's picture with the CI dot on its corner. */
function AgentAvatar({
Expand Down Expand Up @@ -72,7 +51,6 @@ function PullRequestChip({ conversationId }: { conversationId: string }) {
hideTimeout: 150,
});
const open = Ariakit.useStoreState(hovercard, 'open');
const cardElement = Ariakit.useStoreState(hovercard, 'contentElement');
const { data, isError, refetch } = useConversationPullRequestQuery(conversationId);
const pullRequest = data?.pullRequest;

Expand Down Expand Up @@ -108,21 +86,12 @@ function PullRequestChip({ conversationId }: { conversationId: string }) {
</Ariakit.Button>
}
/>
<Ariakit.Hovercard
gutter={8}
portal
unmountOnHide
autoFocusOnShow={false}
aria-label={localize('com_ui_pull_request')}
className={cardClass}
>
<PullRequestCard
pullRequest={pullRequest}
refreshFailed={isError}
onRetry={() => void refetch()}
portalElement={cardElement}
/>
</Ariakit.Hovercard>
<PullRequestPanel
store={hovercard}
pullRequest={pullRequest}
refreshFailed={isError}
onRetry={() => void refetch()}
/>
</Ariakit.HovercardProvider>
);
}
Expand Down
25 changes: 25 additions & 0 deletions client/src/components/Chat/PullRequest/CiDot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { cn } from '~/utils';

/**
* The CI status dot that sits on the corner of the picture or icon it describes. The ring is
* painted in the surface the dot sits on, so the caller names that surface.
*/
export default function CiDot({
dotClass,
ringClassName = 'ring-presentation',
}: {
dotClass: string;
ringClassName?: string;
}) {
return (
<span
data-testid="pull-request-ci-dot"
aria-hidden="true"
className={cn(
'pointer-events-none absolute -right-0.5 -bottom-0.5 size-2 rounded-full ring-2',
ringClassName,
dotClass,
)}
/>
);
}
58 changes: 58 additions & 0 deletions client/src/components/Chat/PullRequest/Panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import * as Ariakit from '@ariakit/react';
import type { TConversationPullRequest } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
import PullRequestCard from './Card';
import { cn } from '~/utils';

/** Slides in from the anchor's side, left to right, and fades; reduced motion only fades. */
export const panelClass = cn(
'border-border-light bg-surface-secondary text-text-primary z-[200] w-80 max-w-[calc(100vw-2rem)] rounded-xl border shadow-lg focus:outline-none',
'origin-left -translate-x-3 opacity-0 transition duration-200 ease-out',
'data-[enter]:translate-x-0 data-[enter]:opacity-100',
'data-[leave]:-translate-x-3 data-[leave]:opacity-0',
'motion-reduce:translate-x-0 motion-reduce:transition-opacity',
);

/**
* The card as a hovercard beside its anchor. It is portaled, and a portal's events still bubble
* to the React tree that rendered it, so anything that sits inside a clickable row stops them
* here: a click on the card must not open the row it hangs from.
*/
export default function PullRequestPanel({
store,
pullRequest,
refreshFailed,
onRetry,
}: {
store: Ariakit.HovercardStore;
pullRequest: TConversationPullRequest;
refreshFailed: boolean;
onRetry: () => void;
}) {
const localize = useLocalize();
const panelElement = Ariakit.useStoreState(store, 'contentElement');
const stop = (event: { stopPropagation: () => void }) => event.stopPropagation();

return (
<Ariakit.Hovercard
store={store}
gutter={8}
portal
unmountOnHide
autoFocusOnShow={false}
aria-label={localize('com_ui_pull_request')}
className={panelClass}
onClick={stop}
onDoubleClick={stop}
onContextMenu={stop}
onKeyDown={stop}
>
<PullRequestCard
pullRequest={pullRequest}
refreshFailed={refreshFailed}
onRetry={onRetry}
portalElement={panelElement}
/>
</Ariakit.Hovercard>
);
}
Loading
Loading