mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
fix: reassign default provider when deleting the current default (#1108)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,6 +46,21 @@ function hasObjectChanges<T extends Record<string, unknown>>(
|
||||
return keys.some((key) => JSON.stringify(existing[key]) !== JSON.stringify(patch[key]));
|
||||
}
|
||||
|
||||
function selectReplacementDefaultAccount(
|
||||
accounts: ProviderAccount[],
|
||||
deletedAccountId: string,
|
||||
): ProviderAccount | undefined {
|
||||
return accounts
|
||||
.filter((account) => account.id !== deletedAccountId)
|
||||
.sort((left, right) => {
|
||||
if (left.enabled !== right.enabled) {
|
||||
return left.enabled ? -1 : 1;
|
||||
}
|
||||
const updatedAtOrder = right.updatedAt.localeCompare(left.updatedAt);
|
||||
return updatedAtOrder !== 0 ? updatedAtOrder : left.id.localeCompare(right.id);
|
||||
})[0];
|
||||
}
|
||||
|
||||
function payloadString(payload: unknown, key: string): string | undefined {
|
||||
if (typeof payload === 'string') return payload;
|
||||
if (!isRecord(payload)) return undefined;
|
||||
@@ -362,7 +377,16 @@ async function deleteAccount(
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
const currentDefaultAccountId = await providerService.getDefaultAccountId();
|
||||
const replacementDefault = currentDefaultAccountId === accountId
|
||||
? selectReplacementDefaultAccount(await providerService.listAccounts(), accountId)
|
||||
: undefined;
|
||||
|
||||
await providerService.deleteAccount(accountId);
|
||||
if (replacementDefault) {
|
||||
await providerService.setDefaultAccount(replacementDefault.id);
|
||||
await syncDefaultProviderToRuntime(replacementDefault.id);
|
||||
}
|
||||
await syncDeletedProviderToRuntime(
|
||||
existing ? providerAccountToConfig(existing) : null,
|
||||
accountId,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
id: provider-default-invariant
|
||||
title: Provider Default Invariant
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- gateway-backend-communication
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
---
|
||||
|
||||
Provider account deletion must keep the ClawX account store and OpenClaw runtime default model aligned.
|
||||
|
||||
Rules:
|
||||
|
||||
- when deleting the current default and at least one provider account remains, exactly one remaining account must be selected as the default
|
||||
- deleting the current default account must select a remaining enabled account before any disabled account
|
||||
- replacement selection must be deterministic, preferring the most recently updated account within the same enabled state
|
||||
- deleting a non-default account must not change the current default
|
||||
- deleting the last account may leave the default account and OpenClaw default model unset
|
||||
- replacement defaults must be persisted through the provider service and synchronized to OpenClaw before the deleted provider runtime config is removed
|
||||
- tests must cover deleting the default account, a non-default account, and the last account
|
||||
@@ -32,6 +32,7 @@ requiredRules:
|
||||
- channel-plugin-migration-guards
|
||||
- capability-owner-resolution
|
||||
- active-config-guards
|
||||
- provider-default-invariant
|
||||
- provider-model-metadata-preservation
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: reassign-default-provider-after-delete
|
||||
title: Reassign the default provider after deleting it
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Keep ClawX provider account state and the OpenClaw default model valid when the user deletes the current default provider while other provider accounts remain.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/reassign-default-provider-after-delete.md
|
||||
- harness/specs/rules/provider-default-invariant.md
|
||||
- harness/specs/scenarios/gateway-backend-communication.md
|
||||
- electron/services/providers-api.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/e2e/provider-lifecycle.spec.ts
|
||||
expectedUserBehavior:
|
||||
- Deleting the current default provider automatically promotes a remaining provider to default.
|
||||
- The promoted provider remains default after the app reloads its provider snapshot.
|
||||
- Deleting a non-default provider leaves the current default unchanged.
|
||||
- Deleting the final provider leaves no default provider.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
- e2e
|
||||
requiredRules:
|
||||
- active-config-guards
|
||||
- backend-communication-boundary
|
||||
- provider-default-invariant
|
||||
- renderer-main-boundary
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/e2e/provider-lifecycle.spec.ts
|
||||
acceptance:
|
||||
- The typed providers deleteAccount host action detects whether the deleted account is the current default.
|
||||
- When accounts remain, replacement selection prefers enabled accounts and then the most recently updated account.
|
||||
- The replacement default is persisted and synchronized to OpenClaw before the deleted provider runtime configuration is removed.
|
||||
- Deleting a non-default account does not invoke default-provider persistence or runtime synchronization.
|
||||
- Deleting the last account does not attempt to assign a replacement default.
|
||||
- Renderer transport boundaries remain unchanged.
|
||||
- Focused unit tests, Electron E2E coverage, harness validation, communication replay, and communication compare pass.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
Deleting a provider account currently removes `defaultProviderAccountId` and
|
||||
the matching `agents.defaults.model.primary` value when that provider was the
|
||||
default. The delete path does not promote another configured account, leaving
|
||||
the Models page and OpenClaw runtime without a default even when usable
|
||||
providers remain.
|
||||
|
||||
## Scope
|
||||
|
||||
- Select a deterministic replacement when the current default account is
|
||||
deleted.
|
||||
- Persist and synchronize that replacement through the existing Main-owned
|
||||
provider APIs.
|
||||
- Add unit and Electron E2E regression coverage.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Adding a replacement-provider chooser to the delete UI.
|
||||
- Changing account ordering in the Models page.
|
||||
- Assigning a default when no provider accounts remain.
|
||||
- README updates because the documented provider management interface and
|
||||
workflow do not change.
|
||||
@@ -20,6 +20,52 @@ async function seedTestProvider(page: Parameters<typeof completeSetup>[0]): Prom
|
||||
}
|
||||
|
||||
test.describe('ClawX provider lifecycle', () => {
|
||||
test('promotes a remaining provider after deleting the default provider', async ({ page }) => {
|
||||
await completeSetup(page);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const now = new Date().toISOString();
|
||||
const providers = [
|
||||
{
|
||||
id: 'moonshot-default-e2e',
|
||||
name: 'Moonshot Default E2E',
|
||||
type: 'moonshot',
|
||||
baseUrl: 'https://api.moonshot.cn/v1',
|
||||
model: 'kimi-k2.6',
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: 'deepseek-replacement-e2e',
|
||||
name: 'DeepSeek Replacement E2E',
|
||||
type: 'deepseek',
|
||||
baseUrl: 'https://api.deepseek.com/v1',
|
||||
model: 'deepseek-v4-pro',
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: new Date(Date.now() + 1_000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
for (const provider of providers) {
|
||||
await window.electron.ipcRenderer.invoke('provider:save', provider);
|
||||
}
|
||||
await window.electron.ipcRenderer.invoke('provider:setDefault', providers[0].id);
|
||||
});
|
||||
|
||||
await page.getByTestId('sidebar-nav-models').click();
|
||||
await expect(page.getByTestId('provider-card-moonshot-default-e2e')).toContainText('Default');
|
||||
await expect(page.getByTestId('provider-card-deepseek-replacement-e2e')).toBeVisible();
|
||||
|
||||
await page.getByTestId('provider-card-moonshot-default-e2e').hover();
|
||||
await page.getByTestId('provider-delete-moonshot-default-e2e').click();
|
||||
|
||||
await expect(page.getByTestId('provider-card-moonshot-default-e2e')).toHaveCount(0);
|
||||
await expect(page.getByTestId('provider-card-deepseek-replacement-e2e')).toContainText('Default');
|
||||
await expect(page.getByTestId('provider-set-default-deepseek-replacement-e2e')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('shows a saved provider and removes it cleanly after deletion', async ({ page }) => {
|
||||
await completeSetup(page);
|
||||
await seedTestProvider(page);
|
||||
|
||||
@@ -32,6 +32,7 @@ const {
|
||||
saveChannelConfigMock,
|
||||
setSettingMock,
|
||||
syncDefaultProviderToRuntimeMock,
|
||||
syncDeletedProviderToRuntimeMock,
|
||||
syncSavedProviderToRuntimeMock,
|
||||
syncLaunchAtStartupSettingFromStoreMock,
|
||||
syncProxyConfigToOpenClawMock,
|
||||
@@ -99,6 +100,7 @@ const {
|
||||
saveChannelConfigMock: vi.fn(),
|
||||
setSettingMock: vi.fn(),
|
||||
syncDefaultProviderToRuntimeMock: vi.fn(),
|
||||
syncDeletedProviderToRuntimeMock: vi.fn(),
|
||||
syncSavedProviderToRuntimeMock: vi.fn(),
|
||||
syncLaunchAtStartupSettingFromStoreMock: vi.fn(),
|
||||
syncProxyConfigToOpenClawMock: vi.fn(),
|
||||
@@ -192,7 +194,7 @@ vi.mock('@electron/services/providers/provider-runtime-sync', () => ({
|
||||
syncAgentModelOverrideToRuntime: vi.fn(),
|
||||
syncDefaultProviderToRuntime: (...args: unknown[]) => syncDefaultProviderToRuntimeMock(...args),
|
||||
syncDeletedProviderApiKeyToRuntime: vi.fn(),
|
||||
syncDeletedProviderToRuntime: vi.fn(),
|
||||
syncDeletedProviderToRuntime: (...args: unknown[]) => syncDeletedProviderToRuntimeMock(...args),
|
||||
syncProviderApiKeyToRuntime: vi.fn(),
|
||||
syncSavedProviderToRuntime: (...args: unknown[]) => syncSavedProviderToRuntimeMock(...args),
|
||||
syncUpdatedProviderToRuntime: vi.fn(),
|
||||
@@ -481,6 +483,116 @@ describe('host services', () => {
|
||||
expect(syncDefaultProviderToRuntimeMock).toHaveBeenCalledWith('custom-local', gatewayManager);
|
||||
});
|
||||
|
||||
it('promotes the newest enabled account before removing the deleted default from runtime', async () => {
|
||||
const deletedAccount = {
|
||||
id: 'default-account',
|
||||
vendorId: 'moonshot',
|
||||
label: 'Default',
|
||||
authMode: 'api_key',
|
||||
model: 'kimi-k2.6',
|
||||
enabled: true,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
};
|
||||
const newestDisabledAccount = {
|
||||
...deletedAccount,
|
||||
id: 'disabled-newest',
|
||||
label: 'Disabled Newest',
|
||||
enabled: false,
|
||||
updatedAt: '2026-06-03T00:00:00.000Z',
|
||||
};
|
||||
const olderEnabledAccount = {
|
||||
...deletedAccount,
|
||||
id: 'enabled-older',
|
||||
label: 'Enabled Older',
|
||||
updatedAt: '2026-06-01T00:00:00.000Z',
|
||||
};
|
||||
const newestEnabledAccount = {
|
||||
...deletedAccount,
|
||||
id: 'enabled-newest',
|
||||
label: 'Enabled Newest',
|
||||
updatedAt: '2026-06-02T00:00:00.000Z',
|
||||
};
|
||||
providerServiceMock.getAccount.mockResolvedValue(deletedAccount);
|
||||
providerServiceMock.getDefaultAccountId.mockResolvedValue(deletedAccount.id);
|
||||
providerServiceMock.listAccounts.mockResolvedValue([
|
||||
deletedAccount,
|
||||
newestDisabledAccount,
|
||||
olderEnabledAccount,
|
||||
newestEnabledAccount,
|
||||
]);
|
||||
const gatewayManager = { debouncedReload: vi.fn(), debouncedRestart: vi.fn() };
|
||||
const { createProvidersApi } = await import('@electron/services/providers-api');
|
||||
|
||||
await expect(createProvidersApi({
|
||||
gatewayManager: gatewayManager as never,
|
||||
mainWindow: {} as never,
|
||||
}).deleteAccount({ accountId: deletedAccount.id })).resolves.toEqual({ success: true });
|
||||
|
||||
expect(providerServiceMock.setDefaultAccount).toHaveBeenCalledWith(newestEnabledAccount.id);
|
||||
expect(syncDefaultProviderToRuntimeMock).toHaveBeenCalledWith(newestEnabledAccount.id);
|
||||
expect(syncDeletedProviderToRuntimeMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: deletedAccount.id, type: deletedAccount.vendorId }),
|
||||
deletedAccount.id,
|
||||
gatewayManager,
|
||||
undefined,
|
||||
);
|
||||
expect(syncDefaultProviderToRuntimeMock.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(syncDeletedProviderToRuntimeMock.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('does not change the default provider when deleting a non-default account', async () => {
|
||||
const account = {
|
||||
id: 'secondary-account',
|
||||
vendorId: 'moonshot',
|
||||
label: 'Secondary',
|
||||
authMode: 'api_key',
|
||||
model: 'kimi-k2.6',
|
||||
enabled: true,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
};
|
||||
providerServiceMock.getAccount.mockResolvedValue(account);
|
||||
providerServiceMock.getDefaultAccountId.mockResolvedValue('default-account');
|
||||
const gatewayManager = { debouncedReload: vi.fn(), debouncedRestart: vi.fn() };
|
||||
const { createProvidersApi } = await import('@electron/services/providers-api');
|
||||
|
||||
await expect(createProvidersApi({
|
||||
gatewayManager: gatewayManager as never,
|
||||
mainWindow: {} as never,
|
||||
}).deleteAccount({ accountId: account.id })).resolves.toEqual({ success: true });
|
||||
|
||||
expect(providerServiceMock.listAccounts).not.toHaveBeenCalled();
|
||||
expect(providerServiceMock.setDefaultAccount).not.toHaveBeenCalled();
|
||||
expect(syncDefaultProviderToRuntimeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the default unset when deleting the final provider account', async () => {
|
||||
const account = {
|
||||
id: 'only-account',
|
||||
vendorId: 'moonshot',
|
||||
label: 'Only Account',
|
||||
authMode: 'api_key',
|
||||
model: 'kimi-k2.6',
|
||||
enabled: true,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
};
|
||||
providerServiceMock.getAccount.mockResolvedValue(account);
|
||||
providerServiceMock.getDefaultAccountId.mockResolvedValue(account.id);
|
||||
providerServiceMock.listAccounts.mockResolvedValue([account]);
|
||||
const gatewayManager = { debouncedReload: vi.fn(), debouncedRestart: vi.fn() };
|
||||
const { createProvidersApi } = await import('@electron/services/providers-api');
|
||||
|
||||
await expect(createProvidersApi({
|
||||
gatewayManager: gatewayManager as never,
|
||||
mainWindow: {} as never,
|
||||
}).deleteAccount({ accountId: account.id })).resolves.toEqual({ success: true });
|
||||
|
||||
expect(providerServiceMock.setDefaultAccount).not.toHaveBeenCalled();
|
||||
expect(syncDefaultProviderToRuntimeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('builds channel accounts from config without gateway rpc in config mode', async () => {
|
||||
const openClawConfig = {
|
||||
channels: {
|
||||
|
||||
Reference in New Issue
Block a user