mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-12 09:45:47 +00:00
🗂️ feat: Choose Conversation Checkout Mode (#16679)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / publish (push) Waiting to run
Frontend Unit Tests / Client build recovery regression (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / publish (push) Waiting to run
Frontend Unit Tests / Client build recovery regression (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* feat: Choose the conversation checkout mode * fix: Preserve checkout choices across submission and recovery * test: Match checkout policy defaults and share mode enum * fix: Offer shared checkout and require deliberate transition modes * fix: Retain explicit mode when changing repository selection * test: Match repository menu accessible labels
This commit is contained in:
parent
bb0543fd15
commit
35a2bee409
20 changed files with 888 additions and 66 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { useId, useState } from 'react';
|
||||
import { Fragment, useId, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { isCodeWorkspaceCheckoutAvailable } from 'librechat-data-provider';
|
||||
import { TooltipAnchor, composerControlClasses, useToastContext } from '@librechat/client';
|
||||
import { Check, ChevronDown, Folder, FolderSync, FolderX, RefreshCw, Monitor } from 'lucide-react';
|
||||
import type { CodeWorkspaceSelection, TConversation } from 'librechat-data-provider';
|
||||
|
|
@ -103,10 +104,11 @@ function describeTransition(
|
|||
/** A sole advertised workspace is the only valid pick, so it counts as chosen until changed. */
|
||||
function chosenWorkspaceId(
|
||||
target: CodeWorkspaceEnvironmentResult,
|
||||
choices: Record<string, string>,
|
||||
choices: Record<string, CodeWorkspaceSelection>,
|
||||
): string | undefined {
|
||||
const choice = choices[target.environment.id];
|
||||
if (choice != null && target.workspaces.some(({ id }) => id === choice)) return choice;
|
||||
if (choice != null && target.workspaces.some(({ id }) => id === choice.workspaceId))
|
||||
return choice.workspaceId;
|
||||
return target.workspaces.length === 1 ? target.workspaces[0].id : undefined;
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +120,8 @@ function EnvironmentWorkspaces({
|
|||
hideOnClick,
|
||||
isSelected,
|
||||
onSelect,
|
||||
checkout,
|
||||
allowCheckoutSelection = false,
|
||||
}: {
|
||||
environment: CodeWorkspaceEnvironmentResult['environment'];
|
||||
requiredBy?: CodeWorkspaceEnvironmentResult['requiredBy'];
|
||||
|
|
@ -126,6 +130,8 @@ function EnvironmentWorkspaces({
|
|||
hideOnClick: boolean;
|
||||
isSelected: (workspaceId: string) => boolean;
|
||||
onSelect: (selection: CodeWorkspaceSelection) => void;
|
||||
checkout?: CodeWorkspaceSelection['checkout'];
|
||||
allowCheckoutSelection?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
|
|
@ -146,47 +152,106 @@ function EnvironmentWorkspaces({
|
|||
{workspaces.map((descriptor) => {
|
||||
const selected = isSelected(descriptor.id);
|
||||
return (
|
||||
<Ariakit.MenuItemRadio
|
||||
key={descriptor.id}
|
||||
name={`codeWorkspace:${environment.id}`}
|
||||
value={descriptor.id}
|
||||
checked={selected}
|
||||
hideOnClick={hideOnClick}
|
||||
onChange={() => onSelect({ environmentId: environment.id, workspaceId: descriptor.id })}
|
||||
className={menuItemClasses(selected)}
|
||||
>
|
||||
<Folder className="text-text-secondary mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="text-text-primary truncate text-sm font-medium">
|
||||
{descriptor.name ?? descriptor.id}
|
||||
<Fragment key={descriptor.id}>
|
||||
<Ariakit.MenuItemRadio
|
||||
key={descriptor.id}
|
||||
name={`codeWorkspace:${environment.id}`}
|
||||
value={descriptor.id}
|
||||
checked={selected}
|
||||
hideOnClick={hideOnClick}
|
||||
onChange={() =>
|
||||
onSelect({ environmentId: environment.id, workspaceId: descriptor.id })
|
||||
}
|
||||
className={menuItemClasses(selected)}
|
||||
>
|
||||
<Folder className="text-text-secondary mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="text-text-primary truncate text-sm font-medium">
|
||||
{descriptor.name ?? descriptor.id}
|
||||
</div>
|
||||
{descriptor.name && (
|
||||
<p className="text-text-secondary truncate text-xs">{descriptor.id}</p>
|
||||
)}
|
||||
{descriptor.instructions !== undefined && (
|
||||
<p className="text-text-secondary truncate text-xs">
|
||||
{descriptor.instructions.length === 0
|
||||
? localize('com_ui_repository_instructions_none')
|
||||
: descriptor.instructions
|
||||
.map(
|
||||
(file) =>
|
||||
`${file.path} · ${(file.bytes / 1024).toFixed(1)} KB${file.truncated ? ` · ${localize('com_ui_repository_instructions_truncated')}` : ''}`,
|
||||
)
|
||||
.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{(descriptor.environment?.repo || descriptor.environment?.ref) && (
|
||||
<p className="text-text-secondary truncate text-xs">
|
||||
{[descriptor.environment.repo, descriptor.environment.ref]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{descriptor.name && (
|
||||
<p className="text-text-secondary truncate text-xs">{descriptor.id}</p>
|
||||
{selected && (
|
||||
<Check className="text-text-primary mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
{descriptor.instructions !== undefined && (
|
||||
<p className="text-text-secondary truncate text-xs">
|
||||
{descriptor.instructions.length === 0
|
||||
? localize('com_ui_repository_instructions_none')
|
||||
: descriptor.instructions
|
||||
.map(
|
||||
(file) =>
|
||||
`${file.path} · ${(file.bytes / 1024).toFixed(1)} KB${file.truncated ? ` · ${localize('com_ui_repository_instructions_truncated')}` : ''}`,
|
||||
)
|
||||
.join(', ')}
|
||||
</p>
|
||||
</Ariakit.MenuItemRadio>
|
||||
{selected &&
|
||||
allowCheckoutSelection &&
|
||||
environment.configSchema?.workspaces?.allowCheckoutSelection === true && (
|
||||
<>
|
||||
<Ariakit.MenuHeading render={<div />} className={headingClasses}>
|
||||
{localize('com_ui_code_checkout_mode')}
|
||||
</Ariakit.MenuHeading>
|
||||
{(['isolated', 'source'] as const)
|
||||
.filter(
|
||||
(mode) =>
|
||||
mode === 'source' ||
|
||||
descriptor.workspaceInstances?.includes('git_worktree'),
|
||||
)
|
||||
.map((mode) => (
|
||||
<Ariakit.MenuItemRadio
|
||||
key={mode}
|
||||
name={`codeCheckout:${environment.id}`}
|
||||
value={mode}
|
||||
checked={checkout === mode}
|
||||
hideOnClick={hideOnClick}
|
||||
className={menuItemClasses(checkout === mode)}
|
||||
onChange={() =>
|
||||
onSelect({
|
||||
environmentId: environment.id,
|
||||
workspaceId: descriptor.id,
|
||||
checkout: mode,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="text-text-primary text-sm font-medium">
|
||||
{localize(
|
||||
mode === 'isolated'
|
||||
? 'com_ui_code_checkout_isolated'
|
||||
: 'com_ui_code_checkout_source',
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-secondary text-xs">
|
||||
{localize(
|
||||
mode === 'isolated'
|
||||
? 'com_ui_code_checkout_isolated_info'
|
||||
: 'com_ui_code_checkout_source_info',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{checkout === mode && (
|
||||
<Check
|
||||
className="text-text-primary mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Ariakit.MenuItemRadio>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{(descriptor.environment?.repo || descriptor.environment?.ref) && (
|
||||
<p className="text-text-secondary truncate text-xs">
|
||||
{[descriptor.environment.repo, descriptor.environment.ref]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selected && (
|
||||
<Check className="text-text-primary mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
</Ariakit.MenuItemRadio>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -240,7 +305,7 @@ export default function CodeWorkspaceMenu({
|
|||
const { refresh, isRefreshing } = useCodeWorkspaceRefresh();
|
||||
const [moveDraft, setMoveDraft] = useState<{
|
||||
conversationId: string;
|
||||
workspaces: Record<string, string>;
|
||||
workspaces: Record<string, CodeWorkspaceSelection>;
|
||||
} | null>(null);
|
||||
|
||||
if (!workspace.visible) return null;
|
||||
|
|
@ -272,6 +337,12 @@ export default function CodeWorkspaceMenu({
|
|||
const { transition } = workspace;
|
||||
const environmentIds = new Set(workspace.environments.map(({ environment }) => environment.id));
|
||||
const selectWorkspace = (selection: CodeWorkspaceSelection) => {
|
||||
const previous = workspace.environments.find(
|
||||
({ environment }) => environment.id === selection.environmentId,
|
||||
)?.selected;
|
||||
if (selection.checkout == null && previous?.checkout != null) {
|
||||
selection = { ...selection, checkout: previous.checkout };
|
||||
}
|
||||
workspace.rememberSelection(selection);
|
||||
const replacedIds = new Set(
|
||||
workspace.machineOptionGroups?.filter((ids) => ids.includes(selection.environmentId)).flat(),
|
||||
|
|
@ -329,12 +400,24 @@ export default function CodeWorkspaceMenu({
|
|||
const chosenTargets =
|
||||
transition?.targets.flatMap((target) => {
|
||||
const workspaceId = chosenWorkspaceId(target, moveChoices);
|
||||
return workspaceId == null
|
||||
const previous = transition.from?.filter(
|
||||
({ environmentId, agentIds }) =>
|
||||
environmentId === target.environment.id ||
|
||||
agentIds?.some((id) => target.selectionOwners?.includes(id)),
|
||||
);
|
||||
const priorModes = new Set(
|
||||
(previous?.length ? previous : (transition.from ?? [])).map(({ checkout }) => checkout),
|
||||
);
|
||||
const inheritedCheckout = priorModes.size === 1 ? [...priorModes][0] : undefined;
|
||||
/** A mixed predecessor decision requires a deliberate mode, never an automatic fallback. */
|
||||
return workspaceId == null ||
|
||||
(priorModes.size > 1 && moveChoices[target.environment.id]?.checkout == null)
|
||||
? []
|
||||
: [
|
||||
{
|
||||
environmentId: target.environment.id,
|
||||
workspaceId,
|
||||
checkout: moveChoices[target.environment.id]?.checkout ?? inheritedCheckout,
|
||||
...(target.selectionOwners?.length ? { agentIds: target.selectionOwners } : {}),
|
||||
},
|
||||
];
|
||||
|
|
@ -347,7 +430,19 @@ export default function CodeWorkspaceMenu({
|
|||
transition.kind !== 'detach' &&
|
||||
(transition.targets.length > 0 || transition.retained.length > 0);
|
||||
const moveReady =
|
||||
transition != null && chosenTargets.length === transition.targets.length && proposed.length > 0;
|
||||
transition != null &&
|
||||
chosenTargets.length === transition.targets.length &&
|
||||
proposed.length > 0 &&
|
||||
chosenTargets.every((selection) => {
|
||||
const target = transition.targets.find(
|
||||
({ environment }) => environment.id === selection.environmentId,
|
||||
);
|
||||
return isCodeWorkspaceCheckoutAvailable(
|
||||
selection,
|
||||
target?.workspaces.find(({ id }) => id === selection.workspaceId),
|
||||
target?.environment.configSchema?.workspaces?.allowCheckoutSelection === true,
|
||||
);
|
||||
});
|
||||
const applyTransition = (to: CodeWorkspaceSelection[]) => {
|
||||
if (transition == null || disabled || moveMutation.isLoading) return;
|
||||
moveMutation.mutate(
|
||||
|
|
@ -389,6 +484,18 @@ export default function CodeWorkspaceMenu({
|
|||
if (onlyDescriptor && (workspace.machineOptions?.length ?? 0) > 1) {
|
||||
label = `${onlyEnvironment?.environment.name ?? onlyEnvironment?.environment.id} · ${label}`;
|
||||
}
|
||||
if (onlyEnvironment?.selected?.checkout != null) {
|
||||
label = `${label} · ${localize(onlyEnvironment.selected.checkout === 'isolated' ? 'com_ui_code_checkout_isolated' : 'com_ui_code_checkout_source')}`;
|
||||
}
|
||||
const checkoutSummaries = workspace.environments.flatMap(
|
||||
({ environment, selected, workspaces }) => {
|
||||
if (selected?.checkout == null) return [];
|
||||
const descriptor = workspaces.find(({ id }) => id === selected.workspaceId);
|
||||
return [
|
||||
`${environment.name ?? environment.id} · ${descriptor?.name ?? selected.workspaceId} · ${localize(selected.checkout === 'isolated' ? 'com_ui_code_checkout_isolated' : 'com_ui_code_checkout_source')}`,
|
||||
];
|
||||
},
|
||||
);
|
||||
const Icon =
|
||||
workspace.mode === 'without_attached' ||
|
||||
workspace.state === 'missing' ||
|
||||
|
|
@ -459,6 +566,12 @@ export default function CodeWorkspaceMenu({
|
|||
<RefreshCw className="text-text-secondary size-3 shrink-0" aria-hidden="true" />
|
||||
</TooltipAnchor>
|
||||
<WorkspaceRequirements id={requirementsId} requirements={requirements} />
|
||||
{workspace.environments.length > 1 && (
|
||||
<WorkspaceRequirements
|
||||
id={`${requirementsId}-checkouts`}
|
||||
requirements={checkoutSummaries}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -537,10 +650,25 @@ export default function CodeWorkspaceMenu({
|
|||
emptyLabel={localize('com_ui_code_workspace_unavailable')}
|
||||
hideOnClick={false}
|
||||
isSelected={(workspaceId) => chosenWorkspaceId(target, moveChoices) === workspaceId}
|
||||
onSelect={({ environmentId, workspaceId }) =>
|
||||
allowCheckoutSelection={true}
|
||||
checkout={
|
||||
chosenTargets.find(({ environmentId }) => environmentId === target.environment.id)
|
||||
?.checkout
|
||||
}
|
||||
onSelect={(selection) =>
|
||||
setMoveDraft({
|
||||
conversationId: transition.conversationId,
|
||||
workspaces: { ...moveChoices, [environmentId]: workspaceId },
|
||||
workspaces: {
|
||||
...moveChoices,
|
||||
[selection.environmentId]: {
|
||||
...selection,
|
||||
checkout:
|
||||
selection.checkout ??
|
||||
chosenTargets.find(
|
||||
({ environmentId }) => environmentId === selection.environmentId,
|
||||
)?.checkout,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
|
@ -632,6 +760,8 @@ export default function CodeWorkspaceMenu({
|
|||
workspace.mode === 'attached' && workspaceId === selected?.workspaceId
|
||||
}
|
||||
onSelect={selectWorkspace}
|
||||
checkout={selected?.checkout}
|
||||
allowCheckoutSelection={!workspace.locked}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
|
@ -711,6 +841,12 @@ export default function CodeWorkspaceMenu({
|
|||
</span>
|
||||
</Ariakit.MenuItem>
|
||||
</Ariakit.Menu>
|
||||
{workspace.environments.length > 1 && (
|
||||
<WorkspaceRequirements
|
||||
id={`${requirementsId}-checkouts`}
|
||||
requirements={checkoutSummaries}
|
||||
/>
|
||||
)}
|
||||
</Ariakit.MenuProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,159 @@ function renderMenu(ui: React.ReactElement) {
|
|||
}
|
||||
|
||||
describe('CodeWorkspaceMenu', () => {
|
||||
test.each(['source', 'isolated'] as const)(
|
||||
'keeps explicit %s when choosing another repository on the same machine',
|
||||
async (checkout) => {
|
||||
const graph = workspace();
|
||||
graph.environments[0].selected = {
|
||||
environmentId: environment.id,
|
||||
workspaceId: 'project-a',
|
||||
checkout,
|
||||
};
|
||||
graph.environments[0].workspaces.push({ id: 'project-b', name: 'Project B' });
|
||||
const setConversation = jest.fn();
|
||||
renderMenu(
|
||||
<CodeWorkspaceMenu setConversation={setConversation} workspace={graph} disabled={false} />,
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('code-workspace'));
|
||||
await userEvent.click(screen.getByRole('menuitemradio', { name: /Project B/ }));
|
||||
expect(setConversation.mock.calls[0][0](conversation)).toMatchObject({
|
||||
codeWorkspaces: [{ environmentId: environment.id, workspaceId: 'project-b', checkout }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('reports checkout modes for every environment on a restored sealed chat', () => {
|
||||
const graph = workspace({ locked: true, transition: undefined });
|
||||
graph.environments = ['source', 'isolated'].map((checkout, index) => ({
|
||||
environment: { ...environment, id: `vm-${index}`, name: `Machine ${index}` },
|
||||
state: 'ready' as const,
|
||||
workspaces: [{ id: 'repo', name: `Repository ${index}` }],
|
||||
selected: {
|
||||
environmentId: `vm-${index}`,
|
||||
workspaceId: 'repo',
|
||||
checkout: checkout as 'source' | 'isolated',
|
||||
},
|
||||
}));
|
||||
renderMenu(
|
||||
<CodeWorkspaceMenu setConversation={jest.fn()} workspace={graph} disabled={false} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Machine 0 · Repository 0 · com_ui_code_checkout_source'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Machine 1 · Repository 1 · com_ui_code_checkout_isolated'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each(['source', 'isolated'] as const)(
|
||||
'materializes an explicit %s choice from a legacy automatic selection',
|
||||
async (checkout) => {
|
||||
const selected = { environmentId: environment.id, workspaceId: 'project-a' };
|
||||
const graph = workspace({
|
||||
environments: [
|
||||
{
|
||||
environment: {
|
||||
...environment,
|
||||
configSchema: { workspaces: { allowCheckoutSelection: true } },
|
||||
},
|
||||
state: 'ready',
|
||||
workspaces: [{ id: 'project-a', workspaceInstances: ['git_worktree'] }],
|
||||
selected,
|
||||
},
|
||||
],
|
||||
});
|
||||
const setConversation = jest.fn();
|
||||
renderMenu(
|
||||
<CodeWorkspaceMenu setConversation={setConversation} workspace={graph} disabled={false} />,
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('code-workspace'));
|
||||
const isolated = screen.getByRole('menuitemradio', {
|
||||
name: /com_ui_code_checkout_isolated/,
|
||||
});
|
||||
const source = screen.getByRole('menuitemradio', { name: /com_ui_code_checkout_source/ });
|
||||
expect(isolated).toHaveAttribute('aria-checked', 'false');
|
||||
expect(source).toHaveAttribute('aria-checked', 'false');
|
||||
await userEvent.click(checkout === 'isolated' ? isolated : source);
|
||||
expect(setConversation.mock.calls[0][0](conversation)).toMatchObject({
|
||||
codeWorkspaces: [{ ...selected, checkout }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.each(['source', 'isolated'] as const)(
|
||||
'saves an explicit %s checkout choice before sending',
|
||||
async (checkout) => {
|
||||
const selected = { environmentId: environment.id, workspaceId: 'project-a' };
|
||||
const graph = workspace({
|
||||
environments: [
|
||||
{
|
||||
environment: {
|
||||
...environment,
|
||||
configSchema: { workspaces: { allowCheckoutSelection: true } },
|
||||
},
|
||||
state: 'ready',
|
||||
workspaces: [{ id: 'project-a', workspaceInstances: ['git_worktree'] }],
|
||||
selected: { ...selected, checkout: checkout === 'source' ? 'isolated' : 'source' },
|
||||
},
|
||||
],
|
||||
});
|
||||
const setConversation = jest.fn();
|
||||
renderMenu(
|
||||
<CodeWorkspaceMenu setConversation={setConversation} workspace={graph} disabled={false} />,
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('code-workspace'));
|
||||
await userEvent.click(
|
||||
screen.getByRole('menuitemradio', {
|
||||
name: new RegExp(
|
||||
checkout === 'source' ? 'com_ui_code_checkout_source' : 'com_ui_code_checkout_isolated',
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(setConversation.mock.calls[0][0](conversation)).toMatchObject({
|
||||
codeEnvironmentMode: 'attached',
|
||||
codeWorkspaces: [{ ...selected, checkout }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ enabled: false, capable: true },
|
||||
{ enabled: true, capable: false },
|
||||
])('gates each checkout choice by policy and capability: %j', async ({ enabled, capable }) => {
|
||||
const graph = workspace();
|
||||
graph.environments[0].environment = {
|
||||
...environment,
|
||||
configSchema: { workspaces: { allowCheckoutSelection: enabled } },
|
||||
};
|
||||
graph.environments[0].workspaces = [
|
||||
{
|
||||
id: 'project-a',
|
||||
...(capable ? { workspaceInstances: ['git_worktree'] as ['git_worktree'] } : {}),
|
||||
},
|
||||
];
|
||||
const setConversation = jest.fn();
|
||||
renderMenu(
|
||||
<CodeWorkspaceMenu setConversation={setConversation} workspace={graph} disabled={false} />,
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('code-workspace'));
|
||||
if (!enabled) {
|
||||
expect(screen.queryByText('com_ui_code_checkout_mode')).not.toBeInTheDocument();
|
||||
} else {
|
||||
expect(
|
||||
screen.queryByRole('menuitemradio', { name: /com_ui_code_checkout_isolated/ }),
|
||||
).not.toBeInTheDocument();
|
||||
await userEvent.click(
|
||||
screen.getByRole('menuitemradio', { name: /com_ui_code_checkout_source/ }),
|
||||
);
|
||||
expect(setConversation.mock.calls[0][0](conversation)).toMatchObject({
|
||||
codeWorkspaces: [
|
||||
{ environmentId: environment.id, workspaceId: 'project-a', checkout: 'source' },
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('explains the missing reviewer workspace while the selected primary workspace is ready', async () => {
|
||||
const reviewerMachine = { ...environment, id: 'reviewer-vm', name: 'Danny Skynet Trusted VM' };
|
||||
const graph = workspace({ state: 'choose', canSubmit: false });
|
||||
|
|
@ -809,6 +962,126 @@ describe('CodeWorkspaceMenu', () => {
|
|||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test.each(['source', 'isolated'] as const)(
|
||||
'chooses an explicit %s checkout during attachment',
|
||||
async (checkout) => {
|
||||
const selectedTarget = target(
|
||||
{ ...environment, configSchema: { workspaces: { allowCheckoutSelection: true } } },
|
||||
[{ id: 'project-a', workspaceInstances: ['git_worktree'] }],
|
||||
);
|
||||
const graph = relocatable([selectedTarget]);
|
||||
graph.transition = { ...graph.transition!, kind: 'attach', from: [], previous: [] };
|
||||
const moveSpy = jest
|
||||
.spyOn(dataService, 'moveConversationCodeEnvironment')
|
||||
.mockResolvedValue({
|
||||
conversationId: 'existing',
|
||||
codeEnvironmentMode: 'attached',
|
||||
codeWorkspaces: [{ ...moved, checkout }],
|
||||
});
|
||||
renderMenu(
|
||||
<CodeWorkspaceMenu setConversation={jest.fn()} workspace={graph} disabled={false} />,
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('code-workspace'));
|
||||
await userEvent.click(
|
||||
screen.getByRole('menuitemradio', {
|
||||
name: new RegExp(
|
||||
checkout === 'source'
|
||||
? 'com_ui_code_checkout_source'
|
||||
: 'com_ui_code_checkout_isolated',
|
||||
),
|
||||
}),
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole('menuitem', { name: /com_ui_code_workspace_attach/ }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(moveSpy).toHaveBeenCalledWith({
|
||||
conversationId: 'existing',
|
||||
from: [],
|
||||
to: [{ ...moved, checkout }],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(['unsupported isolation', 'mixed predecessors'] as const)(
|
||||
'requires an explicit source choice for %s',
|
||||
async (scenario) => {
|
||||
const graph = relocatable([
|
||||
target(
|
||||
{ ...environment, configSchema: { workspaces: { allowCheckoutSelection: true } } },
|
||||
[{ id: 'project-a' }],
|
||||
),
|
||||
]);
|
||||
graph.transition = {
|
||||
...graph.transition!,
|
||||
from:
|
||||
scenario === 'unsupported isolation'
|
||||
? [{ ...mac, checkout: 'isolated' }]
|
||||
: [
|
||||
{ ...mac, checkout: 'isolated' },
|
||||
{ environmentId: 'other-vm', workspaceId: 'repo', checkout: 'source' },
|
||||
],
|
||||
};
|
||||
const moveSpy = jest
|
||||
.spyOn(dataService, 'moveConversationCodeEnvironment')
|
||||
.mockResolvedValue({
|
||||
conversationId: 'existing',
|
||||
codeEnvironmentMode: 'attached',
|
||||
codeWorkspaces: [{ ...moved, checkout: 'source' }],
|
||||
});
|
||||
renderMenu(
|
||||
<CodeWorkspaceMenu setConversation={jest.fn()} workspace={graph} disabled={false} />,
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('code-workspace-move'));
|
||||
expect(await confirmItem()).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(moveSpy).not.toHaveBeenCalled();
|
||||
await userEvent.click(
|
||||
screen.getByRole('menuitemradio', { name: /com_ui_code_checkout_source/ }),
|
||||
);
|
||||
await userEvent.click(await confirmItem());
|
||||
await waitFor(() =>
|
||||
expect(moveSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: [{ ...moved, checkout: 'source' }] }),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('preserves registered checkout when recovering onto a replacement workspace', async () => {
|
||||
const graph = relocatable([
|
||||
target({ ...environment, configSchema: { workspaces: { allowCheckoutSelection: true } } }, [
|
||||
{ id: 'project-a', workspaceInstances: ['git_worktree'] },
|
||||
]),
|
||||
]);
|
||||
const old = {
|
||||
environmentId: environment.id,
|
||||
workspaceId: 'deleted',
|
||||
checkout: 'source' as const,
|
||||
};
|
||||
graph.transition = { ...graph.transition!, from: [old] };
|
||||
const moveSpy = jest.spyOn(dataService, 'moveConversationCodeEnvironment').mockResolvedValue({
|
||||
conversationId: 'existing',
|
||||
codeEnvironmentMode: 'attached',
|
||||
codeWorkspaces: [{ ...moved, checkout: 'source' }],
|
||||
});
|
||||
renderMenu(
|
||||
<CodeWorkspaceMenu setConversation={jest.fn()} workspace={graph} disabled={false} />,
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('code-workspace-move'));
|
||||
expect(
|
||||
screen.getByRole('menuitemradio', { name: /com_ui_code_checkout_source/ }),
|
||||
).toHaveAttribute('aria-checked', 'true');
|
||||
await userEvent.click(await confirmItem());
|
||||
await waitFor(() =>
|
||||
expect(moveSpy).toHaveBeenCalledWith({
|
||||
conversationId: 'existing',
|
||||
from: [old],
|
||||
to: [{ ...moved, checkout: 'source' }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('recovers in the same chat only after confirmation, preserving healthy environments', async () => {
|
||||
const missing = { ...moved, workspaceId: 'deleted-project' };
|
||||
const kept = { environmentId: 'team-vm', workspaceId: 'shared' };
|
||||
|
|
|
|||
|
|
@ -43,6 +43,69 @@ const conversation = (codeWorkspaces?: TConversation['codeWorkspaces']): TConver
|
|||
}) as TConversation;
|
||||
|
||||
describe('useCodeWorkspace', () => {
|
||||
it.each([
|
||||
{ enabled: false, capable: true },
|
||||
{ enabled: true, capable: false },
|
||||
])('blocks a restored isolation choice when support disappears: %j', ({ enabled, capable }) => {
|
||||
mockAgentsConfig().agentsConfig.statefulCodeSessions.environments[0].configSchema = {
|
||||
workspaces: { allowCheckoutSelection: enabled },
|
||||
};
|
||||
mockStatus.mockReturnValue([
|
||||
{
|
||||
data: {
|
||||
environmentId: 'personal-vm',
|
||||
status: 'ready',
|
||||
workspaces: [
|
||||
{ id: 'project-a', ...(capable ? { workspaceInstances: ['git_worktree'] } : {}) },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const selected = [
|
||||
{ environmentId: 'personal-vm', workspaceId: 'project-a', checkout: 'isolated' as const },
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useCodeWorkspace({
|
||||
...conversation(selected),
|
||||
conversationId: 'saved',
|
||||
codeEnvironmentMode: 'attached',
|
||||
}),
|
||||
);
|
||||
expect(result.current.canSubmit).toBe(false);
|
||||
expect(result.current.resolveSubmission(selected)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['source', 'isolated'] as const)(
|
||||
'retains %s checkout through submission and restored-session resolution',
|
||||
(checkout) => {
|
||||
mockAgentsConfig().agentsConfig.statefulCodeSessions.environments[0].configSchema = {
|
||||
workspaces: { allowCheckoutSelection: true },
|
||||
};
|
||||
mockStatus.mockReturnValue([
|
||||
{
|
||||
data: {
|
||||
environmentId: 'personal-vm',
|
||||
status: 'ready',
|
||||
workspaces: [{ id: 'project-a', workspaceInstances: ['git_worktree'] }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const selection = [{ environmentId: 'personal-vm', workspaceId: 'project-a', checkout }];
|
||||
const { result, rerender } = renderHook(
|
||||
({ saved }) =>
|
||||
useCodeWorkspace({
|
||||
...conversation(selection),
|
||||
conversationId: saved ? 'saved' : 'new',
|
||||
codeEnvironmentMode: 'attached',
|
||||
}),
|
||||
{ initialProps: { saved: false } },
|
||||
);
|
||||
expect(result.current.resolveSubmission(selection)?.codeWorkspaces).toEqual(selection);
|
||||
rerender({ saved: true });
|
||||
expect(result.current.resolveSubmission(selection)?.codeWorkspaces).toEqual(selection);
|
||||
},
|
||||
);
|
||||
|
||||
describe('per-chat machines', () => {
|
||||
function enableChoices() {
|
||||
const config = mockAgentsConfig().agentsConfig;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
Tools,
|
||||
isEphemeralAgentId,
|
||||
isCodeWorkspaceSelections,
|
||||
isCodeWorkspaceCheckoutAvailable,
|
||||
resolveCodeEnvironmentSelection,
|
||||
} from 'librechat-data-provider';
|
||||
import {
|
||||
|
|
@ -412,8 +413,15 @@ export default function useCodeWorkspace(
|
|||
) {
|
||||
state = 'unavailable';
|
||||
} else if (status.data.workspaces == null) state = 'unsupported';
|
||||
else if (selected != null) state = 'ready';
|
||||
else if (stored != null) state = 'missing';
|
||||
else if (selected != null) {
|
||||
state = isCodeWorkspaceCheckoutAvailable(
|
||||
selected,
|
||||
workspaces.find(({ id }) => id === selected.workspaceId),
|
||||
environment.configSchema?.workspaces?.allowCheckoutSelection === true,
|
||||
)
|
||||
? 'ready'
|
||||
: 'unsupported';
|
||||
} else if (stored != null) state = 'missing';
|
||||
else if (workspaces.length === 0) state = 'unavailable';
|
||||
return {
|
||||
environment,
|
||||
|
|
@ -448,11 +456,20 @@ export default function useCodeWorkspace(
|
|||
result.state !== 'loading' &&
|
||||
result.state !== 'unavailable' &&
|
||||
result.state !== 'unsupported' &&
|
||||
result.workspaces.some(({ id }) => id === requested.workspaceId)
|
||||
result.workspaces.some(
|
||||
(descriptor) =>
|
||||
descriptor.id === requested.workspaceId &&
|
||||
isCodeWorkspaceCheckoutAvailable(
|
||||
requested,
|
||||
descriptor,
|
||||
result.environment.configSchema?.workspaces?.allowCheckoutSelection === true,
|
||||
),
|
||||
)
|
||||
) {
|
||||
resolved.push({
|
||||
environmentId: result.environment.id,
|
||||
workspaceId: requested.workspaceId,
|
||||
...(requested.checkout == null ? {} : { checkout: requested.checkout }),
|
||||
...(requested.agentIds == null ? {} : { agentIds: requested.agentIds }),
|
||||
});
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -3315,5 +3315,10 @@
|
|||
"com_ui_schedule_consent_error": "Authorization could not be updated. Reload the current state and try again.",
|
||||
"com_ui_schedule_consent_issuer": "Issuer: {{issuer}}",
|
||||
"com_ui_schedule_consent_audience": "Audience: {{audience}}",
|
||||
"com_ui_schedule_consent_scopes": "Scopes: {{scopes}}"
|
||||
"com_ui_schedule_consent_scopes": "Scopes: {{scopes}}",
|
||||
"com_ui_code_checkout_mode": "Checkout mode",
|
||||
"com_ui_code_checkout_isolated": "Isolated worktree",
|
||||
"com_ui_code_checkout_isolated_info": "Created on the first workspace operation from the worker's Git snapshot. Uncommitted changes are not copied.",
|
||||
"com_ui_code_checkout_source": "Registered checkout",
|
||||
"com_ui_code_checkout_source_info": "Use the machine's registered checkout directly. Other chats may share these files."
|
||||
}
|
||||
|
|
|
|||
54
docs/workspace-checkouts.md
Normal file
54
docs/workspace-checkouts.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Conversation checkout choices
|
||||
|
||||
A new coding chat can use the registered checkout directly or request its own
|
||||
isolated worktree. The choice is part of the conversation's existing machine
|
||||
and workspace decision, not a separate browser preference. Opening the picker
|
||||
does not create a checkout; the worker provisions isolation on the first
|
||||
workspace operation.
|
||||
|
||||
## Enable
|
||||
|
||||
First update every LibreChat API replica and client. Then opt in on the attached
|
||||
environment (or the control plane whose schema personal environments inherit):
|
||||
|
||||
```yaml
|
||||
configSchema:
|
||||
workspaces:
|
||||
allowCheckoutSelection: true
|
||||
```
|
||||
|
||||
The isolated choice requires the selected worker workspace to advertise
|
||||
`workspaceInstances: [git_worktree]`. A worker configured with
|
||||
`--conversation-worktree-root` can advertise this capability; use a private
|
||||
storage directory outside its registered roots. Follow the worker's admission,
|
||||
capacity and setup runbook. Do not restart workers with active assignments.
|
||||
Conversation-worktree provisioning and linked-worktree lanes are different
|
||||
worker configurations and cannot be enabled together on the same worker.
|
||||
|
||||
In the composer workspace menu, select a repository, reopen the menu and choose
|
||||
**Isolated worktree** or **Registered checkout**. The recorded mode stays visible
|
||||
after reload. Once the chat's decision is sealed, the picker does not implicitly
|
||||
change that execution target. Continue without a workspace or explicitly attach
|
||||
again through the deployment's supported decision transitions.
|
||||
Those transitions offer the same checkout choices. A registered checkout is
|
||||
available even on a worker without isolation support. Mixed predecessor modes
|
||||
require an explicit choice before confirmation; they never become automatic
|
||||
shared-root execution silently.
|
||||
|
||||
- **Isolated worktree** uses the worker's admitted Git snapshot. It does not copy
|
||||
uncommitted source changes. An unavailable isolation capability fails closed;
|
||||
the next command never falls back to shared source files.
|
||||
- **Registered checkout** uses the existing registered root. Other chats may
|
||||
share those files. Normal workspace authorization, approvals, sandboxing and
|
||||
scheduling still apply.
|
||||
|
||||
No flag and no recorded checkout mode preserves the existing behavior:
|
||||
workers advertising conversation worktrees use them automatically, and other
|
||||
workers operate in the registered root. No migration is needed for old chats.
|
||||
Stored explicit modes require compatible readers; do not enable the flag during
|
||||
a rolling upgrade or roll back to an older reader while such chats are active.
|
||||
|
||||
This control does not select a Git base ref, create a named linked-worktree lane,
|
||||
archive source, or remove dependencies. The descriptor's `environment.ref` is
|
||||
operator metadata, not proof of the source's current HEAD. Arbitrary base-ref
|
||||
selection and archive/restore need additional worker-owned protocol operations.
|
||||
|
|
@ -770,6 +770,17 @@ describe('stateful code approval target binding', () => {
|
|||
{ environmentId: 'environment-a', workspaceId: 'project-a', agentIds: ['agent-a'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['source', 'isolated'] as const)(
|
||||
'retains the %s checkout when stripping live capabilities',
|
||||
(checkout) => {
|
||||
const selected = context();
|
||||
selected.codeWorkspace!.checkout = checkout;
|
||||
expect(getCodeWorkspaceSelections([selected])).toEqual([
|
||||
{ environmentId: 'environment-a', workspaceId: 'project-a', checkout },
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('codeExecutionAuthHeaders', () => {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ export function getCodeWorkspaceSelections(
|
|||
selections.set(workspace.environmentId, {
|
||||
environmentId: workspace.environmentId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
...(workspace.checkout == null ? {} : { checkout: workspace.checkout }),
|
||||
...(workspace.agentIds == null ? {} : { agentIds: [...workspace.agentIds] }),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -452,6 +452,75 @@ describe('resolveCodeExecutionWorkspaceContext', () => {
|
|||
expect(resolved.codeWorkspace?.environment).toEqual(environment);
|
||||
});
|
||||
|
||||
it.each(['source', 'isolated'] as const)(
|
||||
'honors an explicit %s checkout choice',
|
||||
async (checkout) => {
|
||||
jest
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue(
|
||||
workspaceStatus([{ id: 'worktree', workspaceInstances: ['git_worktree'] }]),
|
||||
);
|
||||
const resolved = await resolveCodeExecutionWorkspaceContext({
|
||||
context: {
|
||||
...context,
|
||||
conversationWorkspaceInstanceId: 'c'.repeat(64),
|
||||
codeEnvironmentConfigSchema: { workspaces: { allowCheckoutSelection: true } },
|
||||
},
|
||||
requestedSelections: [{ environmentId: 'personal', workspaceId: 'worktree', checkout }],
|
||||
environments,
|
||||
getAppConfig,
|
||||
});
|
||||
expect(resolved.codeWorkspace?.checkout).toBe(checkout);
|
||||
expect(resolved.codeWorkspace?.workspaceInstanceId).toBe(
|
||||
checkout === 'isolated' ? 'c'.repeat(64) : undefined,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ enabled: false, capable: true, instance: 'c'.repeat(64) },
|
||||
{ enabled: true, capable: false, instance: 'c'.repeat(64) },
|
||||
{ enabled: true, capable: true, instance: undefined },
|
||||
])(
|
||||
'refuses unsupported isolation instead of executing in the source: %j',
|
||||
async ({ enabled, capable, instance }) => {
|
||||
jest
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue(
|
||||
workspaceStatus([
|
||||
{ id: 'worktree', ...(capable ? { workspaceInstances: ['git_worktree'] } : {}) },
|
||||
]),
|
||||
);
|
||||
await expect(
|
||||
resolveCodeExecutionWorkspaceContext({
|
||||
context: {
|
||||
...context,
|
||||
conversationWorkspaceInstanceId: instance,
|
||||
codeEnvironmentConfigSchema: { workspaces: { allowCheckoutSelection: enabled } },
|
||||
},
|
||||
requestedSelections: [
|
||||
{ environmentId: 'personal', workspaceId: 'worktree', checkout: 'isolated' },
|
||||
],
|
||||
environments,
|
||||
getAppConfig,
|
||||
}),
|
||||
).rejects.toMatchObject({ reason: 'unsupported' });
|
||||
},
|
||||
);
|
||||
|
||||
it('refuses a checkout choice that changes the sealed decision', async () => {
|
||||
const selection = { environmentId: 'personal', workspaceId: 'worktree' };
|
||||
await expect(
|
||||
resolveCodeExecutionWorkspaceContext({
|
||||
context,
|
||||
persistedSelections: [{ ...selection, checkout: 'isolated' }],
|
||||
requestedSelections: [{ ...selection, checkout: 'source' }],
|
||||
environments,
|
||||
getAppConfig,
|
||||
}),
|
||||
).rejects.toMatchObject({ reason: 'locked' });
|
||||
});
|
||||
|
||||
it('activates a server-derived instance only when the worker advertises worktree support', async () => {
|
||||
const workspaceInstanceId = 'c'.repeat(64);
|
||||
jest
|
||||
|
|
@ -496,7 +565,9 @@ describe('resolveCodeExecutionWorkspaceContext', () => {
|
|||
resolveCodeExecutionWorkspaceContext({
|
||||
context: {
|
||||
...context,
|
||||
codeEnvironmentConfigSchema: { workspaces: { linkedWorktrees } },
|
||||
codeEnvironmentConfigSchema: {
|
||||
workspaces: { linkedWorktrees, allowCheckoutSelection: false },
|
||||
},
|
||||
...(instanceId ? { conversationWorkspaceInstanceId: instanceId } : {}),
|
||||
},
|
||||
requestedSelections: [{ environmentId: 'personal', workspaceId }],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { logger } from '@librechat/data-schemas';
|
|||
import {
|
||||
isCodeWorkspaceSelections,
|
||||
canonicalizeCodeWorkspaceSelections,
|
||||
isCodeWorkspaceCheckoutAvailable,
|
||||
} from 'librechat-data-provider';
|
||||
import type { CodeWorkspaceSelection } from 'librechat-data-provider';
|
||||
import type { CodeEnvironmentConfig, CodeExecutionContext } from '~/agents/execution';
|
||||
|
|
@ -141,21 +142,28 @@ export async function resolveCodeExecutionWorkspaceContext({
|
|||
if (!workspace) {
|
||||
throw new CodeWorkspaceSelectionError('missing');
|
||||
}
|
||||
const supportsIsolation = workspace.workspaceInstances?.includes('git_worktree') === true;
|
||||
if (
|
||||
!isCodeWorkspaceCheckoutAvailable(
|
||||
selection,
|
||||
workspace,
|
||||
context.codeEnvironmentConfigSchema?.workspaces?.allowCheckoutSelection === true,
|
||||
) ||
|
||||
(selection.checkout === 'isolated' && !context.conversationWorkspaceInstanceId)
|
||||
) {
|
||||
throw new CodeWorkspaceSelectionError('unsupported');
|
||||
}
|
||||
const usesIsolation =
|
||||
selection.checkout !== 'source' && supportsIsolation && context.conversationWorkspaceInstanceId;
|
||||
return {
|
||||
...context,
|
||||
codeWorkspace: {
|
||||
...selection,
|
||||
operations: [...(workspace.operations ?? status.operations)],
|
||||
...(context.conversationWorkspaceInstanceId &&
|
||||
workspace.workspaceInstances?.includes('git_worktree')
|
||||
? { workspaceInstanceId: context.conversationWorkspaceInstanceId }
|
||||
: {}),
|
||||
...(usesIsolation ? { workspaceInstanceId: context.conversationWorkspaceInstanceId } : {}),
|
||||
...(context.codeEnvironmentConfigSchema?.workspaces?.linkedWorktrees === true &&
|
||||
workspace.workspaceScopes?.includes('git_linked_worktree') &&
|
||||
!(
|
||||
context.conversationWorkspaceInstanceId &&
|
||||
workspace.workspaceInstances?.includes('git_worktree')
|
||||
)
|
||||
!usesIsolation
|
||||
? { linkedWorktrees: true }
|
||||
: {}),
|
||||
...(status.maxCommandTimeoutMs == null
|
||||
|
|
|
|||
|
|
@ -1546,6 +1546,7 @@ describe('moving a sealed conversation code-environment decision', () => {
|
|||
|
||||
function setup({
|
||||
movesEnabled = true,
|
||||
checkoutEnabled = false,
|
||||
allowAttachDetach = true,
|
||||
stored = {
|
||||
conversationId: 'conversation-1',
|
||||
|
|
@ -1557,6 +1558,7 @@ describe('moving a sealed conversation code-environment decision', () => {
|
|||
fetchImpl = jest.fn().mockImplementation(async () => workerStatusResponse()),
|
||||
}: {
|
||||
movesEnabled?: boolean;
|
||||
checkoutEnabled?: boolean;
|
||||
allowAttachDetach?: boolean;
|
||||
stored?: StoredDecision;
|
||||
job?: CodeEnvironmentGenerationJob;
|
||||
|
|
@ -1607,7 +1609,12 @@ describe('moving a sealed conversation code-environment decision', () => {
|
|||
endpoints: {
|
||||
[EModelEndpoint.agents]: {
|
||||
statefulCodeSessions: {
|
||||
environments: [controlPlane],
|
||||
environments: [
|
||||
{
|
||||
...controlPlane,
|
||||
configSchema: { workspaces: { allowCheckoutSelection: checkoutEnabled } },
|
||||
},
|
||||
],
|
||||
conversationMoves: { enabled: movesEnabled, allowAttachDetach },
|
||||
},
|
||||
},
|
||||
|
|
@ -1673,6 +1680,39 @@ describe('moving a sealed conversation code-environment decision', () => {
|
|||
};
|
||||
}
|
||||
|
||||
test.each([
|
||||
{ checkout: 'source', enabled: false, capable: true, accepted: false },
|
||||
{ checkout: 'isolated', enabled: true, capable: false, accepted: false },
|
||||
{ checkout: 'isolated', enabled: false, capable: true, accepted: false },
|
||||
{ checkout: 'source', enabled: true, capable: false, accepted: true },
|
||||
{ checkout: 'isolated', enabled: true, capable: true, accepted: true },
|
||||
] as const)(
|
||||
'validates checkout before persisting a move: %j',
|
||||
async ({ checkout, enabled, capable, accepted }) => {
|
||||
const { move, replaceDecision } = setup({
|
||||
checkoutEnabled: enabled,
|
||||
fetchImpl: jest.fn(async () =>
|
||||
workerStatusResponse({
|
||||
workspaces: [
|
||||
{ id: vm.workspaceId, ...(capable ? { workspaceInstances: ['git_worktree'] } : {}) },
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
const to = [{ ...vm, checkout }];
|
||||
const res = await move({ from: [mac], to });
|
||||
expect(res.statusCode).toBe(accepted ? 200 : 409);
|
||||
if (accepted) {
|
||||
expect(replaceDecision).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ codeWorkspaces: to }),
|
||||
);
|
||||
} else {
|
||||
expect(res.body).toEqual(expect.objectContaining({ reason: 'unsupported' }));
|
||||
expect(replaceDecision).not.toHaveBeenCalled();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('moves a sealed decision onto a workspace the new machine registers', async () => {
|
||||
const { move, conversations, fetchImpl } = setup();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { logger, type AppConfig } from '@librechat/data-schemas';
|
||||
import { EModelEndpoint, isCodeWorkspaceCheckoutAvailable } from 'librechat-data-provider';
|
||||
import type { CodeEnvironmentMode, CodeWorkspaceSelection } from 'librechat-data-provider';
|
||||
import type { Response } from 'express';
|
||||
import type {
|
||||
|
|
@ -363,9 +363,23 @@ export function createCodeEnvironmentHttpHandlers(deps: CodeEnvironmentHttpDeps)
|
|||
if (!current.workspaces || !current.operations) {
|
||||
throw new CodeWorkspaceSelectionError('unsupported');
|
||||
}
|
||||
if (!current.workspaces.some(({ id }) => id === selection.workspaceId)) {
|
||||
const workspace = current.workspaces.find(({ id }) => id === selection.workspaceId);
|
||||
if (workspace == null) {
|
||||
throw new CodeWorkspaceSelectionError('missing');
|
||||
}
|
||||
const configuration = policy.configurations.find(({ id }) => id === selection.environmentId);
|
||||
const effectiveEnvironment = configuration
|
||||
? configuredAttachedControlPlane(policy.effectiveConfig, configuration.controlPlaneId ?? '')
|
||||
: configuredControlPlane(policy.effectiveConfig, selection.environmentId);
|
||||
if (
|
||||
!isCodeWorkspaceCheckoutAvailable(
|
||||
selection,
|
||||
workspace,
|
||||
effectiveEnvironment?.configSchema?.workspaces?.allowCheckoutSelection === true,
|
||||
)
|
||||
) {
|
||||
throw new CodeWorkspaceSelectionError('unsupported');
|
||||
}
|
||||
if (
|
||||
previousWorkspaceId != null &&
|
||||
previousWorkspaceId !== selection.workspaceId &&
|
||||
|
|
|
|||
|
|
@ -1,7 +1,44 @@
|
|||
import { resolveCodeEnvironmentSelection } from './workspace';
|
||||
import {
|
||||
resolveCodeEnvironmentSelection,
|
||||
isCodeWorkspaceSelection,
|
||||
canonicalizeCodeWorkspaceSelections,
|
||||
} from './workspace';
|
||||
import { EModelEndpoint, tConvoUpdateSchema } from '../schemas';
|
||||
import { appendAgentIdSuffix } from '../agents/identity';
|
||||
import createPayload from '../createPayload';
|
||||
|
||||
describe('chat machine selection', () => {
|
||||
it.each(['source', 'isolated'] as const)(
|
||||
'validates and retains the %s checkout in the sealed identity',
|
||||
(checkout) => {
|
||||
const selection = { environmentId: 'vm', workspaceId: 'repo', checkout };
|
||||
expect(isCodeWorkspaceSelection(selection)).toBe(true);
|
||||
expect(canonicalizeCodeWorkspaceSelections([selection])).toEqual([selection]);
|
||||
const conversation = {
|
||||
conversationId: null,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
endpointType: null,
|
||||
codeWorkspaces: [selection],
|
||||
};
|
||||
expect(tConvoUpdateSchema.parse(conversation).codeWorkspaces).toEqual([selection]);
|
||||
expect(
|
||||
createPayload({
|
||||
conversation,
|
||||
endpointOption: { endpoint: EModelEndpoint.agents },
|
||||
userMessage: { text: 'hello' },
|
||||
codeEnvironmentMode: 'attached',
|
||||
codeWorkspaces: [selection],
|
||||
} as Parameters<typeof createPayload>[0]).payload.codeWorkspaces,
|
||||
).toEqual([selection]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, 'automatic', 'invalid', {}, 1])('rejects an invalid checkout: %j', (checkout) => {
|
||||
expect(isCodeWorkspaceSelection({ environmentId: 'vm', workspaceId: 'repo', checkout })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
const defaultId = 'application-vm';
|
||||
const selections = [{ environmentId: 'runtime-vm', workspaceId: 'primary' }];
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export const CODE_WORKSPACE_OPERATIONS = [
|
|||
'execute_command',
|
||||
] as const;
|
||||
export const CODE_WORKSPACE_INSTANCE_TYPES = ['git_worktree'] as const;
|
||||
export const CODE_WORKSPACE_CHECKOUT_MODES = ['source', 'isolated'] as const;
|
||||
/** Scheduling scopes a worker can admit beneath one registered root. */
|
||||
export const CODE_WORKSPACE_SCOPES = ['git_linked_worktree'] as const;
|
||||
export const CODE_WORKSPACE_SELECTION_ERROR_REASONS = [
|
||||
|
|
@ -127,10 +128,27 @@ export function isCodeWorkspaceEnvironment(
|
|||
export interface CodeWorkspaceSelection {
|
||||
environmentId: string;
|
||||
workspaceId: string;
|
||||
/** Omitted preserves the worker's legacy automatic isolation policy. */
|
||||
checkout?: (typeof CODE_WORKSPACE_CHECKOUT_MODES)[number];
|
||||
/** Explicit graph-agent ownership of a chat machine choice; absent on legacy selections. */
|
||||
agentIds?: string[];
|
||||
}
|
||||
|
||||
/** Explicit isolation never falls back to shared files when a capability or policy disappears. */
|
||||
export function isCodeWorkspaceCheckoutAvailable(
|
||||
selection: Pick<CodeWorkspaceSelection, 'checkout'>,
|
||||
workspace: Pick<CodeWorkspaceDescriptor, 'workspaceInstances'> | undefined,
|
||||
allowSelection: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
selection.checkout == null ||
|
||||
(allowSelection &&
|
||||
workspace != null &&
|
||||
(selection.checkout === 'source' ||
|
||||
workspace.workspaceInstances?.includes('git_worktree') === true))
|
||||
);
|
||||
}
|
||||
|
||||
export function isCodeEnvironmentMode(value: unknown): value is CodeEnvironmentMode {
|
||||
return CODE_ENVIRONMENT_MODES.some((mode) => mode === value);
|
||||
}
|
||||
|
|
@ -148,12 +166,15 @@ export function isCodeWorkspaceSelection(value: unknown): value is CodeWorkspace
|
|||
const selection = value as Record<string, unknown>;
|
||||
return (
|
||||
Object.keys(selection).every((key) =>
|
||||
['environmentId', 'workspaceId', 'agentIds'].includes(key),
|
||||
['environmentId', 'workspaceId', 'agentIds', 'checkout'].includes(key),
|
||||
) &&
|
||||
typeof selection.environmentId === 'string' &&
|
||||
CODE_WORKSPACE_ID_PATTERN.test(selection.environmentId) &&
|
||||
typeof selection.workspaceId === 'string' &&
|
||||
CODE_WORKSPACE_ID_PATTERN.test(selection.workspaceId) &&
|
||||
(selection.checkout === undefined ||
|
||||
selection.checkout === 'source' ||
|
||||
selection.checkout === 'isolated') &&
|
||||
(selection.agentIds === undefined ||
|
||||
(Array.isArray(selection.agentIds) &&
|
||||
selection.agentIds.length > 0 &&
|
||||
|
|
@ -189,9 +210,10 @@ export function canonicalizeCodeWorkspaceSelections(
|
|||
selections: CodeWorkspaceSelection[],
|
||||
): CodeWorkspaceSelection[] {
|
||||
return selections
|
||||
.map(({ environmentId, workspaceId, agentIds }) => ({
|
||||
.map(({ environmentId, workspaceId, agentIds, checkout }) => ({
|
||||
environmentId,
|
||||
workspaceId,
|
||||
...(checkout == null ? {} : { checkout }),
|
||||
...(agentIds == null ? {} : { agentIds: [...agentIds].sort() }),
|
||||
}))
|
||||
.sort((left, right) => left.environmentId.localeCompare(right.environmentId));
|
||||
|
|
|
|||
|
|
@ -963,8 +963,18 @@ describe('attached code environment user config schema', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps checkout selection disabled by default without creating workspace policy', () => {
|
||||
expect(codeEnvironmentUserConfigSchema.parse({})).not.toHaveProperty('workspaces');
|
||||
expect(codeEnvironmentUserConfigSchema.parse({ workspaces: {} }).workspaces).toEqual({
|
||||
allowCheckoutSelection: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ linkedWorktrees: true }, true],
|
||||
[{ allowCheckoutSelection: true }, true],
|
||||
[{ allowCheckoutSelection: false }, true],
|
||||
[{ allowCheckoutSelection: 'yes' }, false],
|
||||
[{ linkedWorktrees: 'yes' }, false],
|
||||
[{ linkedWorktrees: true, subdirectories: true }, false],
|
||||
])('validates the linked worktree lane toggle %p', (workspaces, valid) => {
|
||||
|
|
|
|||
|
|
@ -1350,6 +1350,9 @@ export const codeEnvironmentUserConfigSchema = z
|
|||
* worker advertises linked-worktree lanes. Omission keeps every request scoped to
|
||||
* its checkout. */
|
||||
linkedWorktrees: z.boolean().optional(),
|
||||
/** Permit explicit per-conversation checkout choices after every API replica supports
|
||||
* them. Omission preserves automatic worker isolation and hides the selector. */
|
||||
allowCheckoutSelection: z.boolean().optional().default(false),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { TFile } from './types/files';
|
|||
import {
|
||||
CODE_ENVIRONMENT_MODES,
|
||||
CODE_WORKSPACE_ID_PATTERN,
|
||||
CODE_WORKSPACE_CHECKOUT_MODES,
|
||||
MAX_AGENT_CODE_ENVIRONMENT_CHOICES,
|
||||
} from './code/workspace';
|
||||
import { userSubmittedMessageFieldPathSchema } from './filters';
|
||||
|
|
@ -1202,6 +1203,7 @@ export const tConversationSchema = z.object({
|
|||
.object({
|
||||
environmentId: z.string().regex(CODE_WORKSPACE_ID_PATTERN),
|
||||
workspaceId: z.string().regex(CODE_WORKSPACE_ID_PATTERN),
|
||||
checkout: z.enum(CODE_WORKSPACE_CHECKOUT_MODES).optional(),
|
||||
agentIds: z
|
||||
.array(z.string().regex(CODE_WORKSPACE_ID_PATTERN))
|
||||
.min(1)
|
||||
|
|
|
|||
|
|
@ -1338,6 +1338,24 @@ describe('Conversation Operations', () => {
|
|||
const userId = 'user123';
|
||||
const unsetFields = { codeEnvironmentMode: 1, codeWorkspaces: 1 };
|
||||
|
||||
it.each(['source', 'isolated'] as const)(
|
||||
'preserves the %s checkout through save, reload and a stale save',
|
||||
async (checkout) => {
|
||||
const conversationId = uuidv4();
|
||||
const codeWorkspaces = [{ ...mac, checkout }];
|
||||
await saveConvo(
|
||||
{ userId },
|
||||
{ conversationId, codeEnvironmentMode: 'attached', codeWorkspaces },
|
||||
);
|
||||
expect((await getConvo(userId, conversationId))?.codeWorkspaces).toEqual(codeWorkspaces);
|
||||
await saveConvo({ userId }, { conversationId, codeWorkspaces: [mac], title: 'Later save' });
|
||||
expect(
|
||||
(await methods.readAdmittedConvoCodeEnvironmentDecision(userId, conversationId))
|
||||
?.codeWorkspaces,
|
||||
).toEqual(codeWorkspaces);
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves explicit agent ownership through save, reload and a stale ordinary save', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const codeWorkspaces = [{ ...mac, agentIds: ['primary', 'reviewer'] }];
|
||||
|
|
|
|||
31
packages/data-schemas/src/schema/defaults.spec.ts
Normal file
31
packages/data-schemas/src/schema/defaults.spec.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Schema, model, deleteModel } from 'mongoose';
|
||||
import { conversationPreset } from './defaults';
|
||||
|
||||
describe('conversation checkout persistence', () => {
|
||||
const Checkout = model(
|
||||
'CheckoutPersistenceTest',
|
||||
new Schema({ codeWorkspaces: conversationPreset.codeWorkspaces }),
|
||||
);
|
||||
|
||||
afterAll(() => deleteModel('CheckoutPersistenceTest'));
|
||||
|
||||
it.each(['source', 'isolated'] as const)(
|
||||
'round trips the %s checkout through the real schema',
|
||||
(checkout) => {
|
||||
const selection = { environmentId: 'machine', workspaceId: 'repo', checkout };
|
||||
const saved = new Checkout({ codeWorkspaces: [selection] });
|
||||
expect(saved.validateSync()).toBeUndefined();
|
||||
expect(saved.toObject().codeWorkspaces).toEqual([selection]);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps legacy decisions field-less and rejects unknown modes', () => {
|
||||
const selection = { environmentId: 'machine', workspaceId: 'repo' };
|
||||
expect(new Checkout({ codeWorkspaces: [selection] }).toObject().codeWorkspaces).toEqual([
|
||||
selection,
|
||||
]);
|
||||
expect(
|
||||
new Checkout({ codeWorkspaces: [{ ...selection, checkout: 'wrong' }] }).validateSync(),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
import { Schema } from 'mongoose';
|
||||
import { CODE_APPROVAL_MODES, CODE_ENVIRONMENT_MODES } from 'librechat-data-provider';
|
||||
import {
|
||||
CODE_APPROVAL_MODES,
|
||||
CODE_ENVIRONMENT_MODES,
|
||||
CODE_WORKSPACE_CHECKOUT_MODES,
|
||||
} from 'librechat-data-provider';
|
||||
|
||||
// @ts-ignore
|
||||
export const conversationPreset: {
|
||||
|
|
@ -129,6 +133,7 @@ export const conversationPreset: {
|
|||
type: {
|
||||
environmentId: { type: StringConstructor; required: boolean };
|
||||
workspaceId: { type: StringConstructor; required: boolean };
|
||||
checkout: { type: StringConstructor; enum: string[]; default: undefined };
|
||||
agentIds: { type: StringConstructor[]; default: undefined };
|
||||
_id: boolean;
|
||||
}[];
|
||||
|
|
@ -330,6 +335,7 @@ export const conversationPreset: {
|
|||
{
|
||||
environmentId: { type: String, required: true },
|
||||
workspaceId: { type: String, required: true },
|
||||
checkout: { type: String, enum: [...CODE_WORKSPACE_CHECKOUT_MODES], default: undefined },
|
||||
agentIds: { type: [String], default: undefined },
|
||||
_id: false,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue