fix: allow replacing unavailable conversation models

Persist the selected model before opening dormant sessions, and reconfigure crashed workers on the same account. Preserve session history and report model removal through the model-unavailable contract instead of a generic runtime failure.
This commit is contained in:
2026-09-21 12:23:04 +08:00
parent d222d17500
commit 0f7093d173
8 changed files with 236 additions and 6 deletions

View File

@@ -82,9 +82,10 @@ async function installCodingFirstChatHost(
hostConnection: HostConnection,
featureComplete = false,
managedCapabilities = false,
removedModel = false,
): Promise<void> {
await electronApp.evaluate(async (_, payload) => {
const { connection, featureComplete, managedCapabilities } = payload;
const { connection, featureComplete, managedCapabilities, removedModel } = payload;
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type MainState = {
captured: CapturedRequest[];
@@ -522,8 +523,8 @@ async function installCodingFirstChatHost(
vendorId: 'custom',
label: 'E2E account',
authMode: 'api_key',
model: 'model-a',
fallbackModels: ['model-b'],
model: removedModel ? 'model-b' : 'model-a',
fallbackModels: removedModel ? [] : ['model-b'],
...(managedCapabilities ? { metadata: { worksSquareModelCapabilitiesV2: {
schemaVersion: 2, fetchedAt: now, refreshStatus: 'fresh', models: {
'model-a': { inputModalities: ['text'], outputModalities: ['text'],
@@ -599,6 +600,9 @@ async function installCodingFirstChatHost(
return respond({ conversation }, 201);
}
if (path === `/api/coding/conversations/${conversation.id}/snapshot`) {
if (removedModel && conversation.model.modelId === 'model-a') {
return respond({ success: false, code: 'CODING_MODEL_UNAVAILABLE', error: '所选模型当前不可用,请重新选择。' }, 409);
}
if (!featureComplete) {
state.snapshotPending = true;
await new Promise<void>((resolve) => { state.releaseSnapshot = resolve; });
@@ -704,6 +708,10 @@ async function installCodingFirstChatHost(
return respond({});
}
if (/^\/api\/coding\/conversations\/[^/]+\/model$/.test(path) && method === 'POST') {
if (removedModel) {
conversation.model = body?.model as typeof configuredModel;
snapshot.conversation.model.model = conversation.model;
}
return respond({ model: { model: body?.model, modelResolution: 'resolved' } });
}
if (/^\/api\/coding\/conversations\/[^/]+\/thinking$/.test(path) && method === 'POST') {
@@ -737,7 +745,7 @@ async function installCodingFirstChatHost(
if (path === '/api/coding/runtime/diagnostics') return respond({ runtime: { revision: { provider: 1, resources: 1 }, workers: [{ conversationId: conversation.id, generation: 1, state: 'running', stage: 'running' }] } });
return respond({ success: false, error: `Unhandled E2E route: ${method} ${path}` }, 404);
});
}, { connection: hostConnection, featureComplete, managedCapabilities });
}, { connection: hostConnection, featureComplete, managedCapabilities, removedModel });
}
async function readState(electronApp: ElectronApplication): Promise<{
@@ -826,6 +834,39 @@ test('conversation menus rename, archive and restore without selecting or stoppi
} finally { await releaseSnapshot(electronApp); }
});
test('a removed saved model can be replaced after snapshot preparation fails', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);
const connection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
await installCodingFirstChatHost(electronApp, connection, true, false, true);
await settleSnapshot(electronApp);
await disableCodingEventSource(page);
await page.reload();
page = await getStableWindow(electronApp);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
await expect(page.getByText('所选模型当前不可用,请重新选择。', { exact: true })).toBeVisible();
const settings = page.getByRole('button', { name: /模型与思考设置/ });
await expect(settings).toBeEnabled();
await settings.click();
await page.getByRole('menuitem', { name: '模型 model-a', exact: true }).click();
await expect(page.getByRole('menuitemradio', { name: 'model-a(当前不可用)' })).toBeDisabled();
await page.getByRole('menuitemradio', { name: 'model-b', exact: true }).click();
await expect(settings).toContainText('model-b');
await expect(page.getByText('所选模型当前不可用,请重新选择。', { exact: true })).toHaveCount(0);
await expect(page.getByText('Durable user fork source', { exact: true })).toBeVisible();
await page.getByTestId('coding-process-group').locator('summary').first().click();
await expect(page.getByText('Durable assistant response', { exact: true })).toBeVisible();
const requests = (await readState(electronApp)).captured;
expect(requests.filter(request => request.path.endsWith('/model') && request.method === 'POST').map(request => request.body)).toEqual([
{ model: { accountId: 'account-e2e', modelId: 'model-b', thinkingLevel: 'off' } },
]);
expect(requests.some(request => request.path.endsWith('/prompt') || request.path.endsWith('/abort'))).toBe(false);
});
test('managed capabilities expose native xhigh and block unsupported image input', async ({ launchElectronApp }) => {
const electronApp = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(electronApp);