diff --git a/api/server/routes/__test-utils__/convos-route-mocks.js b/api/server/routes/__test-utils__/convos-route-mocks.js index ce6a630d933..402c0877dda 100644 --- a/api/server/routes/__test-utils__/convos-route-mocks.js +++ b/api/server/routes/__test-utils__/convos-route-mocks.js @@ -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 }), ), diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 07f4de42348..c6386d6867d 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -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'); }); @@ -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'); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 3336a94d4c7..62b0b7eb191 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -23,6 +23,7 @@ const { resolveCodeWorkspaceInheritanceCapability, resolveCodeEnvironmentTransitionVersion, loadConversationListLimits, + resolvePullRequestCapabilities, } = require('@librechat/api'); const { DEFAULT_MCP_APP_CSP_LIMITS, @@ -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( diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 4c05abb57f6..ef6b220150c 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -21,7 +21,9 @@ const { createBackgroundTaskPolicyMiddleware, createConversationPullRequestHandler, createGitHubPullRequestSource, + createProxyAwareFetch, createPullRequestLookup, + createConversationPullRequestsHandler, backgroundTaskRegistry, createSubagentThreadViewHandler, createGeneratedTitleHandler, @@ -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); @@ -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( diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 632d884eca7..446fe540048 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -2376,6 +2376,7 @@ async function loadToolsForExecution({ environmentId: codeExecutionContext.codeWorkspace.environmentId, workspaceId: codeExecutionContext.codeWorkspace.workspaceId, }, + admittedEpoch: req.resolvedConversation?.codeAttachmentEpoch, getConvoLaneContext, reserveConvoLaneGitSeq, setConvoLaneGit, diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 82f98c1dc7f..213827e73f3 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -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({ @@ -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], @@ -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 }], @@ -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({ @@ -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], diff --git a/client/src/components/Chat/PullRequest/Chip.tsx b/client/src/components/Chat/PullRequest/Chip.tsx index 18c3bde4c15..fbd5763748c 100644 --- a/client/src/components/Chat/PullRequest/Chip.tsx +++ b/client/src/components/Chat/PullRequest/Chip.tsx @@ -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 ( - - ); -} +import CiDot from './CiDot'; /** The agent's picture with the CI dot on its corner. */ function AgentAvatar({ @@ -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; @@ -108,21 +86,12 @@ function PullRequestChip({ conversationId }: { conversationId: string }) { } /> - - void refetch()} - portalElement={cardElement} - /> - + void refetch()} + /> ); } diff --git a/client/src/components/Chat/PullRequest/CiDot.tsx b/client/src/components/Chat/PullRequest/CiDot.tsx new file mode 100644 index 00000000000..0e1e522c376 --- /dev/null +++ b/client/src/components/Chat/PullRequest/CiDot.tsx @@ -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 ( +