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 api/server/routes/__test-utils__/convos-route-mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ module.exports = {
() => (_req, res) => res.status(200).json({ tasks: [] }),
),
createBackgroundTaskPolicyMiddleware: jest.fn(() => (_req, _res, next) => next()),
createGitHubPullRequestSource: jest.fn(() => ({ find: jest.fn() })),
createPullRequestLookup: jest.fn(() => jest.fn()),
createConversationPullRequestHandler: jest.fn(
() => (_req, res) => res.status(200).json({ pullRequest: null }),
),
createBackgroundTaskCancelHandler: jest.fn(
() => (_req, res) => res.status(200).json({ results: [] }),
),
Expand Down
15 changes: 15 additions & 0 deletions api/server/routes/__tests__/convos.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,21 @@ describe('Convos Routes', () => {
});
});

describe('GET /:conversationId/pull-request', () => {
it('is registered and reaches the pull request handler', async () => {
const response = await request(app).get('/api/convos/ordinary/pull-request');

expect(response.status).toBe(200);
expect(response.body).toEqual({ pullRequest: null });
});

it('does not fall through to the conversation read', async () => {
await request(app).get('/api/convos/ordinary/pull-request');

expect(getConvo).not.toHaveBeenCalled();
});
});

describe('POST /import', () => {
const { importConversations } = require('~/server/utils/import');

Expand Down
10 changes: 10 additions & 0 deletions api/server/routes/convos.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const {
createBackgroundTaskIndexHandler,
createBackgroundTaskCancelHandler,
createBackgroundTaskPolicyMiddleware,
createConversationPullRequestHandler,
createGitHubPullRequestSource,
createPullRequestLookup,
backgroundTaskRegistry,
createSubagentThreadViewHandler,
createGeneratedTitleHandler,
Expand Down Expand Up @@ -167,6 +170,12 @@ const backgroundTaskIndexHandler = createBackgroundTaskIndexHandler({
const backgroundTaskCancelHandler = createBackgroundTaskCancelHandler({
registry: backgroundTaskRegistry,
});
const conversationPullRequestHandler = createConversationPullRequestHandler({
getConvoLaneGit: db.getConvoLaneGit,
getAppConfig,
lookup: createPullRequestLookup({ source: createGitHubPullRequestSource({ fetchFn: fetch }) }),
env: process.env,
});
router.use(requireJwtAuth);

const isValidProjectFilter = (projectId) =>
Expand Down Expand Up @@ -239,6 +248,7 @@ router.post(
subagentControlHandler,
);
router.get('/:parentConversationId/subagents', parentSubagentIndexHandler);
router.get('/:conversationId/pull-request', conversationPullRequestHandler);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add a frontend consumer for the PR endpoint

This registers the backend capability, but a repo-wide search of client and packages/client finds no caller of getConversationPullRequest/conversationPullRequest and no pull-request header rendering. Consequently enabling the documented setting cannot show anything to users; add the header query and its loading, empty, success, and failure states before exposing the route.

AGENTS.md reference: AGENTS.md:L49-L52

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is the second of three stacked ones on purpose. The header consumer is #16794, which is based on this branch: it adds the query hook, the chip and card, and the loading, empty, success and failure states, and uses getConversationPullRequest. The route is inert until endpoints.agents.pullRequests is enabled, which defaults to off, so nothing is exposed before the consumer lands. Leaving this open for you to decide whether the stack is acceptable or should be one PR.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the chat-header consumer for the new endpoint

Enabling endpoints.agents.pullRequests still cannot show the advertised pull-request chip: a repo-wide search finds the route and data-provider endpoint/service definitions, but no client hook or component calls conversationPullRequest or renders its response. This leaves the new backend capability without the promised frontend entry point, so operators can complete the documented setup and users will observe no change.

AGENTS.md reference: AGENTS.md:L49-L52

Useful? React with 👍 / 👎.

router.get('/:conversationId/background-tasks', backgroundTaskPolicy, backgroundTaskIndexHandler);
router.post(
'/:conversationId/background-tasks/cancel',
Expand Down
22 changes: 21 additions & 1 deletion api/server/services/ToolService.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const {
AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE,
isFatalAgentInitializationError,
codeExecutionAuthHeaders,
createLaneGitRecorder,
createAttachedWorkspaceBashTool,
createRepositoryInstructionSource,
createRepositoryInstructionLoader,
Expand Down Expand Up @@ -130,7 +131,13 @@ const { createOpenIDSessionTokenProvider } = require('~/server/services/OpenIDSe
const { getMCPRequestContext } = require('~/server/services/MCPRequestContext');
const { recordUsage } = require('~/server/services/Threads');
const { loadTools } = require('~/app/clients/tools/util');
const { findPluginAuthsByKeys, getRoleByName } = require('~/models');
const {
findPluginAuthsByKeys,
getRoleByName,
setConvoLaneGit,
getConvoLaneContext,
reserveConvoLaneGitSeq,
} = require('~/models');
const { getFlowStateManager, getMCPServersRegistry } = require('~/config');
const { getLogStores } = require('~/cache');

Expand Down Expand Up @@ -2386,6 +2393,19 @@ async function loadToolsForExecution({
baseUrl: codeExecutionContext.baseUrl,
workspaceId: codeExecutionContext.codeWorkspace.workspaceId,
workspaceInstanceId: codeExecutionContext.codeWorkspace.workspaceInstanceId,
onLaneGit: await createLaneGitRecorder({
Comment thread
berry-13 marked this conversation as resolved.
enabled: req.config?.endpoints?.agents?.pullRequests?.enabled === true,
user: req.user.id,
conversationId: conversationId ?? runtimeRequestBody?.conversationId,
Comment thread
berry-13 marked this conversation as resolved.
repo: codeExecutionContext.codeWorkspace.environment?.repo,
workspace: {
environmentId: codeExecutionContext.codeWorkspace.environmentId,
workspaceId: codeExecutionContext.codeWorkspace.workspaceId,
},
getConvoLaneContext,
Comment thread
berry-13 marked this conversation as resolved.
reserveConvoLaneGitSeq,
setConvoLaneGit,
}),
linkedWorktrees: codeExecutionContext.codeWorkspace.linkedWorktrees,
nativeSandbox: codeExecutionContext.codeWorkspace.nativeSandbox,
environment: codeExecutionContext.codeWorkspace.environment,
Expand Down
112 changes: 112 additions & 0 deletions api/server/services/__tests__/ToolService.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ const mockGetCachedTools = jest.fn();
const mockSendEvent = jest.fn();
const mockEmitChunk = jest.fn();
const mockCreateAttachedWorkspaceBashTool = jest.fn(() => ({ name: AgentConstants.BASH_TOOL }));
const mockLaneGitRecorder = jest.fn();
const mockCreateLaneGitRecorder = jest.fn(() => mockLaneGitRecorder);
const mockSetConvoLaneGit = jest.fn();
const mockGetConvoLaneContext = jest.fn();
const mockReserveConvoLaneGitSeq = jest.fn();
const attachedWorkspaceOperations = [
'read_file',
'search_text',
Expand Down Expand Up @@ -121,6 +126,7 @@ jest.mock('@librechat/api', () => ({
resolveCodeExecutionWorkspaceContext: (...args) =>
mockResolveCodeExecutionWorkspaceContext(...args),
createAttachedWorkspaceBashTool: (...args) => mockCreateAttachedWorkspaceBashTool(...args),
createLaneGitRecorder: (...args) => mockCreateLaneGitRecorder(...args),
}));

const mockLoadToolsUtil = jest.fn();
Expand Down Expand Up @@ -171,6 +177,9 @@ const mockGetRoleByName = jest.fn();
jest.mock('~/models', () => ({
findPluginAuthsByKeys: jest.fn(),
getRoleByName: (...args) => mockGetRoleByName(...args),
setConvoLaneGit: (...args) => mockSetConvoLaneGit(...args),
getConvoLaneContext: (...args) => mockGetConvoLaneContext(...args),
reserveConvoLaneGitSeq: (...args) => mockReserveConvoLaneGitSeq(...args),
}));
jest.mock('~/config', () => ({
getFlowStateManager: jest.fn(() => mockFlowManager),
Expand Down Expand Up @@ -3221,6 +3230,7 @@ describe('ToolService - Action Capability Gating', () => {
authHeaders: expect.any(Function),
baseUrl: 'http://attached-code.test/v1',
workspaceId: 'project-a',
onLaneGit: mockLaneGitRecorder,
gitIdentity: { name: 'LibreChat Agent', email: 'agent@example.com' },
maxTimeoutMs: 65_000,
defaultTimeoutMs: 60_000,
Expand All @@ -3243,6 +3253,108 @@ describe('ToolService - Action Capability Gating', () => {
expect(result.loadedTools).toContainEqual({ name: AgentConstants.BASH_TOOL });
});

it('records the lane for the requesting user and the resolved conversation', async () => {
const capabilities = [
AgentCapabilities.tools,
AgentCapabilities.execute_code,
AgentCapabilities.stateful_code_sessions,
];
const req = createMockReq(capabilities);
req.config.endpoints[EModelEndpoint.agents].pullRequests = { enabled: true };
req.body = {
conversationId: 'body-convo',
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',
});
mockCreateLaneGitRecorder.mockClear();

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

expect(mockCreateLaneGitRecorder).toHaveBeenCalledWith(
expect.objectContaining({
enabled: true,
user: req.user.id,
conversationId: 'resolved-convo',
workspace: { environmentId: 'personal-machine', workspaceId: 'project-a' },
getConvoLaneContext: expect.any(Function),
reserveConvoLaneGitSeq: expect.any(Function),
setConvoLaneGit: expect.any(Function),
}),
);
const { setConvoLaneGit } = mockCreateLaneGitRecorder.mock.calls[0][0];
const input = { user: 'u', conversationId: 'c', laneGit: { branch: 'main', head: null } };
await setConvoLaneGit(input);
expect(mockSetConvoLaneGit).toHaveBeenCalledWith(input);
});

it.each([
['unset', undefined],
['disabled', { enabled: false }],
])('does not enable lane recording when pull requests are %s', async (_label, setting) => {
const capabilities = [
AgentCapabilities.tools,
AgentCapabilities.execute_code,
AgentCapabilities.stateful_code_sessions,
];
const req = createMockReq(capabilities);
if (setting) req.config.endpoints[EModelEndpoint.agents].pullRequests = setting;
req.body = {
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',
});
mockCreateLaneGitRecorder.mockClear();

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

expect(mockCreateLaneGitRecorder).toHaveBeenCalledWith(
expect.objectContaining({ enabled: false }),
);
});

it('passes negotiated lane and native sandbox capabilities to the attached bash tool', async () => {
const capabilities = [
AgentCapabilities.tools,
Expand Down
28 changes: 28 additions & 0 deletions librechat.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,34 @@ endpoints:
# completionReceiptBatching: true
# # Allow owners to cooperatively cancel ordinary background tools, including BYOM Bash.
# ordinaryToolCancellation: false
# # (optional) Show a conversation's pull request in the chat header. Off by default. It needs
# # a code worker built from LibreChat-AI/code-interpreter#311 or later that reports its
# # branch, with CODEAPI_BRIDGE_LANE_GIT=true set on the Code API; without both, every chat
# # simply shows no pull request. It also needs a GitHub token held on the server. Use a
# # fine-grained token limited to the repositories involved, with read-only "Pull requests",
# # "Checks" and "Contents" permissions. Without "Checks" every matched pull request fails to
# # load, and without "Contents" a chat whose branch has moved on since its last command does.
# pullRequests:
# enabled: false
# # Environment variable reference only, never the token itself.
# token: "${GITHUB_PULL_REQUEST_TOKEN}"
# # Repositories the token may be used for, as owner/name or owner/*. Required: a worker
# # reports its own repository, and only these are ever looked up with the server's token.
# allowedRepositories: ["LibreChat-AI/LibreChat"]
# # Seconds a looked-up pull request is reused before GitHub is asked again (5 to 3600).
# cacheTtlSeconds: 30
# # Results cached per credential before the oldest is evicted (10 to 100000). Raise it when many
# # repositories and branches are active at once, or fresh results are evicted and GitHub is asked again.
# cacheMaxEntries: 500
# # Credentials cached at once (1 to 10000), deployment-wide; the one idle longest is dropped
# # past it. When principals configure different values, the largest applies.
# cacheMaxCredentials: 256
# # Bounds on one lookup. A repository with many check runs reads one page of 100 per request.
# requestTimeoutSeconds: 10 # longest single GitHub request (1 to 60)
# lookupTimeoutSeconds: 30 # longest the whole lookup may take (1 to 120)
# maxCheckRunPages: 10 # pages read before checks are reported as running (1 to 50)
# maxCandidatePullRequests: 10 # pull requests listed per state when matching a reused branch (1 to 100)
# maxHeadComparisons: 3 # candidates compared with the chat's last commit (0 to 20)
# # "run_in_background" makes Code Interpreter tools eligible by default and enables per-tool MCP opt-in.
# # "tool_intents" enables live model-written labels for native tools and opted-in MCP tools.
# # (optional) Require user approval before matching tool calls. Disabled by default.
Expand Down
11 changes: 10 additions & 1 deletion packages/api/src/code/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import type {
import type { createBashProgrammaticToolCallingSchema } from '@librechat/agents';
import type { DynamicStructuredTool } from '@librechat/agents/langchain/tools';
import type { LCTool } from '@librechat/agents';
import type { WorkspaceAdmissionOptions, WorkspaceExecuteCommandResult } from './workspace';
import type {
WorkspaceAdmissionOptions,
WorkspaceExecuteCommandResult,
WorkspaceLaneGit,
} from './workspace';
import type { CodeExecutionContext } from '~/agents/execution';
import type { CodeBridgeFetch } from './bridge';
import {
Expand Down Expand Up @@ -468,11 +472,15 @@ export function createAttachedWorkspaceBashTool({
minCommandAdmissionMs,
linkedWorktrees = false,
nativeSandbox = false,
onLaneGit,
fetchImpl,
}: {
baseUrl: string;
authHeaders: () => Promise<Record<string, string>> | Record<string, string>;
workspaceId: string;
/** Receives the lane's branch and head when a finished command reports them. It is not awaited
* and must not throw: a failure here never changes the command's result. */
onLaneGit?: (laneGit: WorkspaceLaneGit) => void;
workspaceInstanceId?: string;
/** The worker runs each `.worktrees/<name>` in its own lane; a matching `cwd` is routed there. */
linkedWorktrees?: boolean;
Expand Down Expand Up @@ -609,6 +617,7 @@ export function createAttachedWorkspaceBashTool({
throw new Error('Attached workspace returned an unexpected command result.');
}
logger.debug('[BYOMCommand] transport completed', trace);
if (result.laneGit != null) onLaneGit?.(result.laneGit);
Comment thread
berry-13 marked this conversation as resolved.
let content = formatCommandResult(
result,
selectedTimeoutMs,
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from './bridge';
export * from './lifecycle';
export * from './workspace';
export * from './command';
export * from './lane';
export * from './capabilities';
export * from './decision';
export * from './inheritance';
Expand Down
Loading
Loading