🗂️ 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

* 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:
Danny Avila 2026-10-02 14:41:35 -04:00 • committed by GitHub
parent bb0543fd15
commit 35a2bee409
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 888 additions and 66 deletions

View file

@ -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>
);
}

View file

@ -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' };

View file

@ -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;

View file

@ -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;

View file

@ -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."
}

View 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.

View file

@ -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', () => {

View file

@ -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] }),
});
}

View file

@ -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 }],

View file

@ -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

View file

@ -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();

View file

@ -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 &&

View file

@ -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' }];

View file

@ -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));

View file

@ -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) => {

View file

@ -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(),

View file

@ -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)

View file

@ -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'] }];

View 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();
});
});

View file

@ -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,
},