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

View File

@@ -34,6 +34,8 @@ import type {
PromptConversationInput,
} from '../../electron/coding-runtime/contracts';
import { archivePiConversationSession } from '../../electron/coding-runtime/pi/resource-loader';
import { PiProviderConfigError } from '../../electron/coding-runtime/pi/provider-config';
import type { ProductModelRef } from '../../shared/coding-conversation-contracts';
const roots: string[] = [];
const servers: Server[] = [];
@@ -273,6 +275,44 @@ describe('PI-100 coding core Host contract', () => {
});
});
it('switches a removed saved model before preparing the Conversation', async () => {
class RemovedModelRuntime extends InMemoryConversationRuntime {
override async validateModel(model: ProductModelRef) {
if (model.modelId === MODEL.modelId) {
throw new PiProviderConfigError('MODEL_UNAVAILABLE', 'Model is no longer available');
}
return await super.validateModel(model);
}
override async prepare(input: PrepareConversationInput) {
if (input.model.model) await this.validateModel(input.model.model);
return await super.prepare(input);
}
}
const result = await setup(new RemovedModelRuntime());
const conversation = await createConversation(result.conversations);
const nextModel = { ...MODEL, modelId: 'available-model' };
const prepare = vi.spyOn(result.runtime, 'prepare');
await expect(result.conversations.getSnapshot(conversation.id)).rejects.toMatchObject({
code: 'CODING_MODEL_UNAVAILABLE', status: 409,
});
await expect(result.conversations.setModel(conversation.id, MODEL)).rejects.toMatchObject({
code: 'CODING_MODEL_UNAVAILABLE', status: 409,
});
expect((await result.conversations.getConversation(conversation.id)).model).toEqual(MODEL);
await expect(result.conversations.setModel(conversation.id, nextModel)).resolves.toMatchObject({
model: nextModel,
modelResolution: 'resolved',
});
expect(prepare).toHaveBeenLastCalledWith(expect.objectContaining({
model: { model: nextModel, modelResolution: 'resolved' },
}));
expect(await result.projects.conversationStore(result.root).get(conversation.id)).toMatchObject({
id: conversation.id, model: nextModel, modelResolution: 'resolved',
});
});
it('switches a resolved active Conversation model through the target runtime without disposing it', async () => {
const result = await setup();
const conversation = await createConversation(result.conversations);

View File

@@ -12,6 +12,9 @@ import {
createMemoryCodingProjectStorage,
} from '../../electron/coding-projects/project-store';
import { PiConversationRuntime } from '../../electron/coding-runtime/pi/runtime';
import { CodingConversationService } from '../../electron/coding-runtime/conversation-service';
import { CodingProjectService } from '../../electron/coding-projects/project-service';
import { buildPiProviderCatalog, selectPiProviderModel } from '../../electron/coding-runtime/pi/provider-config';
import { PiSessionProjectionError } from '../../electron/coding-runtime/pi/session-projector';
import { PiSessionRegistry } from '../../electron/coding-runtime/pi/session-registry';
import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
@@ -265,6 +268,87 @@ describe('Pi Conversation runtime', () => {
} finally { await runtime.shutdown(); }
});
it.each(['cold', 'idle', 'crashed'] as const)('switches a removed managed model in a %s Conversation without losing history', async (lifecycle) => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-removed-model-'));
roots.push(projectPath);
const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), { createId: () => 'project-removed' });
await createLocalCodingProject({ projectPath }, projectStore);
const model = { accountId: 'niancode-user-models', modelId: 'removed-model', thinkingLevel: 'off' as const };
await createCodingProjectAgent(projectPath, {
id: 'agent-a', avatarId: 'avatar-01', roleName: 'Builder', name: 'Builder',
model, modelResolution: 'resolved',
responsibility: { mission: 'Build', owns: [], boundaries: [], collaborators: [], principles: [] },
});
const store = createCodingConversationStore(projectPath);
const conversation = await store.create({ agentId: 'agent-a', title: 'Keep this conversation', model, modelResolution: 'resolved' });
const session = { piSessionId: 'existing-session', sessionKey: 'existing-key' };
await store.ensureSessionBinding(conversation.id, async () => session);
let enabledModels = ['removed-model', 'deepseek-flash'];
const catalog = () => buildPiProviderCatalog({ accounts: [{
id: model.accountId, vendorId: 'custom', label: 'Managed', authMode: 'api_key',
apiProtocol: 'openai-completions', baseUrl: 'https://gateway.test/v1',
enabled: true, isDefault: true, createdAt: NOW, updatedAt: NOW,
model: enabledModels[0], metadata: { customModels: enabledModels },
}] });
const registry = new PiSessionRegistry({ projectStore });
const workers: RuntimeFakeWorker[] = [];
const openedModels: string[] = [];
const pool = new PiWorkerPool({ openWorker: async ({ conversation: input, generation }) => {
// Use the same durable registry and catalog lookup as the managed worker opener.
const registered = await registry.prepare(input);
selectPiProviderModel(catalog(), registered.conversation.model!);
openedModels.push(registered.conversation.model!.modelId);
const worker = new RuntimeFakeWorker('worker', generation);
worker.setSessionData({
state: { sessionId: session.piSessionId, thinkingLevel: 'off', isStreaming: false, isCompacting: false, pendingMessageCount: 0 },
entries: { entries: [{ type: 'message', id: 'old-user', parentId: null, timestamp: NOW,
message: { role: 'user', content: 'Keep the previous messages', timestamp: 1 } }], leafId: 'old-user' },
});
workers.push(worker);
return { worker, session: registered.session! };
} });
const runtime = new PiConversationRuntime({ pool, registry,
resolveModel: async (candidate) => selectPiProviderModel(catalog(), candidate) });
const service = new CodingConversationService(new CodingProjectService(projectStore), runtime);
try {
if (lifecycle !== 'cold') await service.getSnapshot(conversation.id);
enabledModels = ['deepseek-flash'];
runtime.markProviderStale();
if (lifecycle === 'cold') {
await expect(service.getSnapshot(conversation.id)).rejects.toMatchObject({ code: 'CODING_MODEL_UNAVAILABLE' });
} else if (lifecycle === 'crashed') {
workers[0]!.invalidate();
await expect.poll(() => pool.getState(conversation.id)?.state).toBe('crashed');
}
await expect(service.setModel(conversation.id, { ...model, modelId: 'deepseek-flash' })).resolves.toMatchObject({
model: { modelId: 'deepseek-flash', reasoningChoice: { mode: 'default' } }, modelResolution: 'resolved',
});
const snapshot = await service.getSnapshot(conversation.id);
expect(snapshot.conversation.model.model?.modelId).toBe('deepseek-flash');
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
role: 'user', blocks: expect.arrayContaining([expect.objectContaining({ text: 'Keep the previous messages' })]),
}));
expect(await store.get(conversation.id)).toMatchObject({
id: conversation.id, title: conversation.title, ...session,
model: { modelId: 'deepseek-flash' }, modelResolution: 'resolved',
});
expect(workers.flatMap(worker => worker.requests).filter(command => command.type === 'prompt')).toEqual([]);
if (lifecycle === 'idle') expect(workers[0]!.stopReasons).toEqual([]);
else expect(openedModels.at(-1)).toBe('deepseek-flash');
await service.acceptPrompt({ conversationId: conversation.id, clientRequestId: 'continue-after-switch',
mode: 'prompt', text: 'Continue with the available model', attachments: [] });
expect(workers.flatMap(worker => worker.requests).filter(command => command.type === 'prompt')).toEqual([
expect.objectContaining({ message: 'Continue with the available model' }),
]);
expect(openedModels.at(-1)).toBe('deepseek-flash');
workers.at(-1)!.emit({ type: 'agent_settled' });
await expect.poll(async () => (await runtime.getSnapshot(conversation.id)).run.status).toBe('idle');
} finally {
service.dispose();
await runtime.shutdown();
}
});
it('separates RPC acceptance from settle and changes only the target Conversation model', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-runtime-'));
roots.push(projectPath);