Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
4cf7bed
🧭 feat: Purpose-Built AI Providers Editor (Wave 2)
danny-avila Oct 20, 2026
3fb0ac0
🛠️ feat: MCP Servers Editor (Wave 2)
danny-avila Oct 20, 2026
b35ef73
🔌 feat: Custom Endpoints List and Editor Drawer (Wave 2)
danny-avila Oct 20, 2026
6c84a38
🧩 feat: Model Specs Editor (Wave 2)
danny-avila Oct 20, 2026
843d799
✨ feat: Features Tab Editors (Wave 2)
danny-avila Oct 20, 2026
995c1b3
🐛 fix: Keep Pending Edits Reachable When Showing Set Values Only
danny-avila Oct 22, 2026
a897480
Merge branch 'feat/config-w2-3-specs' into feat/config-w2-4-mcp
danny-avila Oct 22, 2026
0585188
Merge branch 'feat/config-w2-1-providers' into feat/config-w2-2-endpo…
danny-avila Oct 22, 2026
f2a3c5b
Merge branch 'feat/config-w2-2-endpoints' into feat/config-w2-3-specs
danny-avila Oct 22, 2026
4ebfc09
🐛 fix: Harden MCP Server Auth, Rename, Duplicate and Transport Edits
danny-avila Oct 22, 2026
9b8f6da
Merge branch 'feat/config-w2-4-mcp' into feat/config-w2-5-features
danny-avila Oct 22, 2026
020ad7d
🐛 fix: Restore Saved Memory Agent IDs and Read Memory State Correctly
danny-avila Oct 22, 2026
b9d1d02
🔒 fix: Mask Secret Values in Collapsed Provider Summaries
danny-avila Oct 22, 2026
9aea2b8
Merge branch 'feat/config-w2-1-providers' into feat/config-w2-2-endpo…
danny-avila Oct 22, 2026
ecbfe5d
🐛 fix: Restore Undone Endpoint Deletions in Their Original Order
danny-avila Oct 22, 2026
e8e08ac
🐛 fix: Keep Pending Inherited Endpoint Overrides Listed Under Set Val…
danny-avila Oct 22, 2026
1030105
🐛 fix: Store Complete Built-In Parameter Definitions
danny-avila Oct 22, 2026
de1e539
🐛 fix: Insert Header Placeholders Into Header Values Only
danny-avila Oct 22, 2026
0f404a7
Merge branch 'feat/config-w2-2-endpoints' into feat/config-w2-3-specs
danny-avila Oct 22, 2026
3d5460b
🐛 fix: Keep Spec Order When One Save Adds and Removes Specs
danny-avila Oct 22, 2026
16abc8a
🐛 fix: Keep a New Spec's Required Fields Under Set Values Only
danny-avila Oct 22, 2026
c09f17f
🐛 fix: Block Saving Blank or Space-Padded Duplicate Spec Names
danny-avila Oct 22, 2026
3e04456
🐛 fix: Index Only the Prompt Fields a Spec Editor Shows
danny-avila Oct 22, 2026
ebdab4c
🐛 fix: Compare a Spec Rename With the Name It Was Loaded With
danny-avila Oct 22, 2026
5a1c35d
🐛 fix: Don't Inherit Spec Fields Through a Replaced Spec List
danny-avila Oct 22, 2026
e7d8998
🐛 fix: Clear the Agent ID When a Spec Leaves the Agents Endpoint
danny-avila Oct 22, 2026
821bf2a
Merge branch 'feat/config-w2-3-specs' into feat/config-w2-4-mcp
danny-avila Oct 22, 2026
e03a2e5
🔒 fix: Require Stored MCP Secrets Again on Rename and Drop Rename Rec…
danny-avila Oct 22, 2026
5b5be53
🐛 fix: Keep an Overridden Inherited MCP Server Visible Under Set Valu…
danny-avila Oct 22, 2026
a3fbfd8
🐛 fix: Pick MCP Copy Names Free in Any Letter Case
danny-avila Oct 22, 2026
5955f12
🔒 fix: Show Stored MCP Secrets as Their Masked Preview With Replace
danny-avila Oct 22, 2026
c7d1b88
Merge branch 'feat/config-w2-4-mcp' into feat/config-w2-5-features
danny-avila Oct 22, 2026
418417b
🐛 fix: Keep a Stored Memory Agent ID When Switching Back Before Saving
danny-avila Oct 22, 2026
6b58e7f
🐛 fix: Forget the Chosen Memory Agent Definition When the Edit Sessio…
danny-avila Oct 22, 2026
2401894
🐛 fix: Show Every Setting of a Web Search Service in Use, Pending One…
danny-avila Oct 22, 2026
31a367b
🐛 fix: Keep Feature Groups With Pending Edits Under Set Values Only
danny-avila Oct 22, 2026
76c81de
🐛 fix: Keep a Newly Edited Memory Agent Under Set Values Only
danny-avila Oct 22, 2026
e4aea2a
🐛 fix: Drop a Pending Domain List When Its Last Domain Is Removed
danny-avila Oct 22, 2026
c1e3573
Merge branch 'feat/config-w2-4-mcp' into feat/config-w2-5-features
danny-avila Oct 22, 2026
666288f
🎨 style: Format MCP Secret Preview and Rename Validation Code
danny-avila Oct 22, 2026
1247f3e
🎨 style: Format Web Search Service Tests
danny-avila Oct 22, 2026
1aca69b
🐛 fix: Keep Configured Azure Groups Visible When Showing Set Values Only
danny-avila Oct 22, 2026
eb3ff9a
Merge branch 'feat/config-w2-1-providers' into feat/config-w2-2-endpo…
danny-avila Oct 22, 2026
28238c1
Merge branch 'feat/config-w2-2-endpoints' into feat/config-w2-3-specs
danny-avila Oct 22, 2026
5888feb
🐛 fix: Resolve Swapped and Chained Entry Renames as One Batch
danny-avila Oct 22, 2026
1c9f458
🐛 fix: Keep Card State With a Spec When the List Is Reordered
danny-avila Oct 22, 2026
626b7ae
🐛 fix: Align the Spec Editor's Model, Parameter and Unknown Rows With…
danny-avila Oct 22, 2026
8ec1a05
Merge branch 'feat/config-w2-3-specs' into feat/config-w2-4-mcp
danny-avila Oct 22, 2026
4914645
🐛 fix: Ask a Renamed MCP Server Only for Secrets Its Authentication S…
danny-avila Oct 22, 2026
193ab79
🐛 fix: Clear MCP Delete Undo State When the Edit Session Ends
danny-avila Oct 22, 2026
56b3109
🐛 fix: Keep a Renamed MCP Server's Field Edits When Its Rename Is Rev…
danny-avila Oct 22, 2026
dede842
🎨 style: Format the Spec Editor's Parameter Imports
danny-avila Oct 22, 2026
6e47384
Merge branch 'feat/config-w2-3-specs' into feat/config-w2-4-mcp
danny-avila Oct 22, 2026
b0e21b0
Merge branch 'feat/config-w2-4-mcp' into feat/config-w2-5-features
danny-avila Oct 22, 2026
bf90e66
🐛 fix: Remove a Setting Whole When a Reverted Rename Had Dropped It
danny-avila Oct 22, 2026
a283353
Merge branch 'feat/config-w2-4-mcp' into feat/config-w2-5-features
danny-avila Oct 22, 2026
7adaf4a
Merge main (previous level's squash acc25aa) into feat/config-w2-5-fe…
danny-avila Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions e2e/features.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { test, expect } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import { LIVE, LIVE_SKIP_REASON, openConfigTab } from './live';

/**
* Features tab journeys (WS13): the memory agent switch, allowlists, web
* search credentials, the summarization trigger and the content filters
* summary. They need a live LibreChat backend (E2E_LIVE=1, E2E_BASE_URL) and
* clean up what they save. The memory journey runs only when librechat.yaml
* defines the memory agent by ID.
*/

const unsavedBar = (page: Page) => page.getByRole('region', { name: 'Unsaved changes' });
const section = (page: Page, id: string) => page.locator(`#section-${id}`);

async function review(page: Page): Promise<Locator> {
await unsavedBar(page)
.getByRole('button', { name: /^Review & save$/ })
.click();
return page.getByRole('dialog');
}

async function saveAndWait(page: Page) {
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: /^Save$/ }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByText('Changes saved', { exact: true }).last()).toBeVisible();
}

async function setText(field: Locator, value: string) {
await field.fill(value);
await field.press('Tab');
}

test.describe('Features editors', () => {
test.skip(!LIVE, LIVE_SKIP_REASON);
test.describe.configure({ mode: 'serial', timeout: 120_000 });

test('the memory agent switches from a librechat.yaml ID to an inline model and back', async ({
page,
}) => {
await openConfigTab(page, 'features');
const memory = section(page, 'memory');
const existing = memory.getByRole('button', { name: 'Existing agent' });
test.skip(
(await existing.getAttribute('aria-pressed')) !== 'true',
'librechat.yaml does not define the memory agent by ID',
);
await memory.getByRole('button', { name: 'Provider and model' }).click();
await expect(memory.getByText(/Saving hides Agent ID from librechat\.yaml/)).toBeVisible();
await setText(memory.getByLabel('Provider', { exact: true }), 'openAI');
await setText(memory.getByLabel('Model', { exact: true }), 'gpt-4o-mini');
const dialog = await review(page);
await expect(dialog).toContainText('Agent › Agent ID');
await expect(dialog).toContainText('Hidden');
await saveAndWait(page);

await page.reload();
await page.waitForLoadState('networkidle');
await expect(memory.getByRole('button', { name: 'Provider and model' })).toHaveAttribute(
'aria-pressed',
'true',
);
await expect(memory.getByLabel('Model', { exact: true })).toHaveValue('gpt-4o-mini');

await memory
.getByRole('button', { name: /^Revert Agent to its librechat\.yaml value/ })
.click();
await review(page);
await saveAndWait(page);
await expect(memory.getByRole('button', { name: 'Existing agent' })).toHaveAttribute(
'aria-pressed',
'true',
);
});

test('an Actions allowlist entry is validated and never blank', async ({ page }) => {
await openConfigTab(page, 'features');
const actions = section(page, 'actions');
const input = actions.getByLabel('Allowed domains: add a domain');
await input.press('Enter');
await expect(unsavedBar(page)).toHaveCount(0);
await input.fill('not a/domain');
await input.press('Enter');
await expect(actions.getByRole('alert')).toContainText('is not a domain');
await input.fill('api.example.com');
await input.press('Enter');
await expect(actions.getByText(/^Only these domains are allowed \(1\)/)).toBeVisible();
await review(page);
await saveAndWait(page);

await actions.getByRole('button', { name: 'Remove api.example.com' }).click();
await expect(actions.getByRole('alertdialog')).toContainText('Remove the last domain?');
await actions.getByRole('button', { name: 'Remove restriction' }).click();
await expect(actions.getByText(/^No restriction: agent actions can call any/)).toBeVisible();
await review(page);
await saveAndWait(page);
});

test('a web search key is masked, reviewed as hidden and stored encrypted', async ({ page }) => {
await openConfigTab(page, 'features');
const web = section(page, 'webSearch');
await web.getByLabel('Search provider', { exact: true }).selectOption('serper');
const key = web.getByLabel('Serper API key', { exact: true });
await key.fill('serper-e2e-secret-1234567890');
await expect(key).toHaveAttribute('type', 'password');
await key.press('Tab');
await expect(web.getByText('Will be stored encrypted when you save.')).toBeVisible();
const dialog = await review(page);
await expect(dialog).not.toContainText('serper-e2e-secret');
await saveAndWait(page);

await page.reload();
await page.waitForLoadState('networkidle');
await expect(web.getByLabel('Serper API key is configured; value hidden')).toHaveValue(
'...7890',
);
await expect(
web.getByText('Stored encrypted. Only the last characters are shown.'),
).toBeVisible();

await web.getByRole('button', { name: /^Revert Serper API key/ }).click();
await web.getByRole('button', { name: /^Revert Search provider/ }).click();
await review(page);
await saveAndWait(page);
});

test('the summarization trigger saves a typed trigger and a valid number', async ({ page }) => {
await openConfigTab(page, 'features');
const summarization = section(page, 'summarization');
await summarization.getByLabel('Trigger: when to summarize').selectOption('token_ratio');
await expect(summarization.getByLabel('Trigger: value')).toHaveValue('0.8');
await expect(
summarization.getByText("Summarizes once 80% of the model's context window is used."),
).toBeVisible();
const dialog = await review(page);
await expect(dialog).toContainText('Token ratio');
await saveAndWait(page);

await summarization.getByLabel('Trigger: when to summarize').selectOption({ index: 0 });
await review(page);
await saveAndWait(page);
});

test('content filters are a read-only summary', async ({ page }) => {
await openConfigTab(page, 'features');
const filters = section(page, 'filters');
const show = filters.getByRole('button', { name: /show all fields/i });
if (await show.count()) await show.click();
await expect(filters.getByText(/librechat\.yaml/).first()).toBeVisible();
await expect(filters.locator('input, textarea, select')).toHaveCount(0);
});
});
4 changes: 2 additions & 2 deletions src/components/configuration/ConfigTabContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ export function ConfigTabContent({
onChange: onFieldChange,
onResetField,
onDiscardField,
onHideField: sectionDisabled ? undefined : onHideField,
editedValues,
disabled: sectionDisabled,
previewMode,
Expand All @@ -273,8 +274,7 @@ export function ConfigTabContent({
yamlBaseKeys: baseRecordKeys?.[dataKey],
onValidationError,
editSessionId,
onHideField,
onUnhideField,
onUnhideField: sectionDisabled ? undefined : onUnhideField,
onRenameEntry,
};
const content = (
Expand Down
2 changes: 1 addition & 1 deletion src/components/configuration/controls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ describe('constraints helpers', () => {
describe('formatFieldValue / getDefaultHint', () => {
it('shows option labels, units and grouped numbers', () => {
const retention = schemaField('interface.retentionMode');
expect(getDefaultHint(retention, 'temporary', localize)).toBe('Temporary');
expect(getDefaultHint(retention, 'temporary', localize)).toBe('Temporary chats only');
expect(getDefaultHint(schemaField('balance.startBalance'), 20000, localize)).toBe(
'20,000 credits',
);
Expand Down
2 changes: 2 additions & 0 deletions src/components/configuration/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ export function canRoundTrip(
return (getFieldShape(field).values ?? []).some((member) => member === value);
case 'array':
case 'multi-enum':
case 'domain-list':
return isStringList(value);
case 'model-list':
return Array.isArray(value) && value.every(isModelItem);
Expand Down Expand Up @@ -363,6 +364,7 @@ export function fallbackControl(
case 'literal':
return 'select';
case 'multi-enum':
case 'domain-list':
return 'array';
case 'model-list':
return value == null || isStringList(value) ? 'array' : 'code';
Expand Down
Loading
Loading