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

@@ -0,0 +1,49 @@
# Task: Recover model switching after managed model removal
## Identity
- Task ID: 20260921-disabled-model-switch-0e02d13a
- Mode: Feature
- Branch: codex/20260921-disabled-model-switch-0e02d13a-disabled-model-switch
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260921-disabled-model-switch-0e02d13a
- Base commit: d222d175000008bad5f344577679256d22a636c2
- Owner: codex
- Status: Ready for Integration
## Scope
- Reproduce and correct Code model switching when Operations has removed the model saved by an existing Conversation. Preserve the conversation/history and allow explicit selection of an available model without depending on the invalid old model.
## Intent And Constraints
- Official check/start/status passed with the exact feature task, branch, worktree and main base above. Read all 149 peer Scope/Intent/Promotion records; retained historical placeholders as unknown/read-only. Previous model-list-sync and Windows shell tasks are separate ready branches, not on main; neither has a conflicting recovery policy. Audio/WeChat peers are independent.
- Planning Gate Passed after entry/own record, same-base integrated context, accepted ADR-006/model-capabilities authority, architecture/domain, evidence/reflection/commitment context. Positioning templates are not authoritative over AGENTS/README and accepted decisions. Main owns provider validation, metadata and Pi lifecycle; active/uncertain work must not be silently stopped or replayed.
- Apply diagnosing-bugs with a deterministic failing test at the actual model-change service/runtime seam. No subagents, live credentials, user conversation changes, paid inference, installed-file changes, merges or deployment.
## Outcome
- Confirmed two model-switch failures: a cold Conversation first opened Pi using its removed saved model, so the valid replacement was never persisted; a crashed worker on the same account received set_model despite having no live channel. PiProviderConfigError(MODEL_UNAVAILABLE) also lacked the public contract consumed by the service, producing the misleading CODING_RUNTIME_UNAVAILABLE / 503.
- Model selection now waits for an already pending preparation, then uses the existing persist/dispose/prepare path when there is no worker. The replacement is validated before any write. Cached failed registration is forgotten so the next preparation reads the new durable model and original session binding.
- Existing workers retain the normal mutation/uncertainty guards. A crashed worker uses the existing target reconfiguration path even on the same account. No active worker is forcibly disposed to repair a model choice.
- Model unavailability maps to CODING_MODEL_UNAVAILABLE / 409 and the existing actionable UI text. Renderer required no production changes; its existing unavailable-option and selectable alternatives already work once Main no longer requires the old model.
- README documents explicit replacement, retained history, and no automatic selection/replay. No dependencies, backend/operator configuration, installed application or user data changed.
## Verification
- RED: pnpm exec vitest run tests/unit/coding-core-routes.test.ts -t 'switches a removed saved model' failed at service.ensurePrepared before the replacement could be persisted.
- Exact symptom confirmed against unmodified production files with the real Pi runtime, registry and catalog selector: cold preparation returned CODING_RUNTIME_UNAVAILABLE instead of CODING_MODEL_UNAVAILABLE, and same-account switching on a crashed worker returned CODING_RUNTIME_UNAVAILABLE / 503. Original production contents were restored after that comparison. One incidental test teardown write race was removed by settling and awaiting the test prompt before cleanup.
- Diagnostic phase adjustment: broad ranked hypotheses and extra instrumentation were unnecessary after the deterministic service failure and real selector/runtime comparison identified the precise call and error translation. No debug logging or throwaway code retained.
- GREEN: pnpm exec vitest run tests/unit/coding-core-routes.test.ts tests/unit/pi-conversation-runtime.test.ts tests/unit/pi-provider-config.test.ts tests/unit/pi-worker-pool.test.ts — 75 tests passed. Covers removed targets rejected without writes, cold/idle/crashed managed sessions, unchanged IDs/session keys/history, no automatic prompts, successful subsequent explicit prompt, existing active switching, and uncertain-mutation rejection.
- pnpm exec playwright test tests/e2e/pi-coding-first-chat.spec.ts --grep 'removed saved model' — 1 passed in isolated Electron. Covers initial snapshot error, enabled picker, disabled unavailable option, replacement selection, error clearance, retained messages and no automatic prompt/abort. Initial test expected an assistant process message to be visible while its group was collapsed; fixed the test to expand that existing group. Uses mocked Host responses, not live inference.
- pnpm run typecheck, pnpm run build:vite, targeted ESLint, and git diff --check passed. Build emits existing Browserslist/dynamic-import warnings.
- Additional Main type check: pnpm exec tsc --project tsconfig.node.json --noEmit --composite false --incremental false reports 62 errors. Compared against unchanged base production files in the same dependency environment: 62 baseline errors and zero diagnostic differences (ignoring shifted line numbers). No new Main diagnostics. The first attempt without --composite false was rejected by TypeScript because composite requires incremental compilation.
- Reviewed every changed file against the recorded base. No full suite, production provider calls, installer/package smoke or manual installed-client verification was performed.
## Follow-ups
- Integrate this task commit and ship a client build before the installed application receives the fix. Prior model-list synchronization and Windows shell tasks remain separate branches; this fix does not require merging either to run.
- Existing Main type-check failures remain outside this task's scope.
## Promotion Candidates
- Target: .project-docs/20-architecture/data-flow.md and current-state.md. Proposal: explicit model replacement for a dormant Conversation is validated/persisted before runtime preparation; a crashed target reconfigures with the selected model while retaining its durable session. A removed model yields the actionable model-unavailable contract, not a generic runtime error. Evidence: 75 focused tests and the isolated Electron replacement flow above. Future impact: provider catalog removals must not block changing a saved selection; active/uncertain work and history remain protected. Semantic conflicts: none with accepted ADR-006 or managed model authority. Human confirmation: not required for the bug-fix policy; canonical edits remain reserved for a later Integration Gate.

View File

@@ -4,6 +4,8 @@
平台托管模型的图片输入与思考选项由 Works Square 的 `model_capabilities_v2` 下发。未知能力保持未知;会话保存“模型默认 / 关闭 / 开启及原生强度”Pi 请求前按冻结的选择写入供应商参数。配置刷新后失效的选择会在发送前提示重新选择BYOK 模型沿用自身配置。
Code 会话保存的模型被下架后,会提示重新选择可用模型;用户可在原会话中切换,恢复时使用新选择并保留会话与历史消息。切换不会自动重发消息或自动选择替代模型。
Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,源码提供四个模块入口;云智能体需配套配置 WS/Yuxi 服务。模块入口页按 Agents、Code、Canvas、Robot 排列统一采用横向插画卡片Agents 标语为“打造你想象中的AI助手”。工作区左上角入口点击后返回模块入口页
- `Makelore CodeAI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。

View File

@@ -489,7 +489,11 @@ export class CodingConversationService {
try {
selected = await this.runtime.validateModel(model);
} catch (error) { runtimeError(error); }
if (conversation.modelResolution === 'resolved' && conversation.model) {
// A dormant Conversation must not need its previous model to start in order
// to select a replacement (the previous model may have been removed).
await this.prepareFlights.get(conversationId)?.catch(() => undefined);
const worker = this.runtime.getDiagnostics().workers.find((item) => item.conversationId === conversationId);
if (conversation.modelResolution === 'resolved' && conversation.model && worker) {
await this.ensurePrepared(conversationId);
let snapshot: ConversationSnapshot;
try {

View File

@@ -149,11 +149,20 @@ export interface PiWorkerCredentialProjectionSummary {
}
export class PiProviderConfigError extends Error {
readonly publicError?: { code: 'CODING_MODEL_UNAVAILABLE'; message: string; recoverable: true };
constructor(
public readonly code: 'PROVIDER_INVALID' | 'PROVIDER_AUTH_REQUIRED' | 'MODEL_UNAVAILABLE',
message: string,
) {
super(message);
if (code === 'MODEL_UNAVAILABLE') {
this.publicError = {
code: 'CODING_MODEL_UNAVAILABLE',
message: '所选模型当前不可用,请重新选择。',
recoverable: true,
};
}
}
}

View File

@@ -945,7 +945,8 @@ export class PiConversationRuntime implements CodingConversationRuntime {
},
modelResolution: 'resolved',
};
if (snapshot.conversation.model.model?.accountId !== selection.accountId) {
if (snapshot.conversation.model.model?.accountId !== selection.accountId
|| this.pool.getState(input.conversationId)?.state === 'crashed') {
const persisted = await this.registry.setModel(input.conversationId, model);
this.replaceModel(input.conversationId, persisted);
try {

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