Compare commits

...
2 Commits
Author SHA1 Message Date
brother7 576edb2720 docs: integrate unavailable model switching on main
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
2026-09-21 13:56:05 +08:00
brother7 0f7093d173 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.
2026-09-21 12:23:04 +08:00
13 changed files with 305 additions and 7 deletions

No files matched your search

@@ -6,6 +6,12 @@ Main 的 `.makelore/conversations.json` schema v2 是 Conversation 标题和归
重命名、归档和恢复经现有 Host API 修改元数据;`conversation.metadata-changed` 在现有 SSE 上只传项目和会话身份,不伪造运行时 generation/seq。Renderer 合并列表刷新、在重连时重新读取列表,并拒绝跨项目或早于本地变更的旧结果。归档不 dispose runtime、不移动 JSONL;Main 阻止归档会话的新 prompt/steer/follow-up 与 fork,保留已接受请求、现有任务、审批与中止能力。
## Code model replacement
用户在现有 Conversation 中明确选择模型后,Main 先校验新选择。若目标尚无 worker,则先持久化新模型并清除失败准备留下的注册缓存,再沿原 session binding 准备 Pi;旧模型已下架不能成为更换模型的前置条件。已崩溃 worker 即使同账号也用新模型重建目标线程,恢复原历史;已有 worker 保留原来的 mutation/uncertainty 门禁。
Provider catalog 的模型不可用错误投影为 `CODING_MODEL_UNAVAILABLE`,界面提示重新选择,不再误报通用运行时不可用。更换模型不会自动选择替代项、重发消息或为修复选择而强制停止运行任务。
## Cloud knowledge embedding
MakeLore 知识库 → Yuxi 已启用 embedding 目录与知识库/发布快照 → Yuxi 直连供应商 → Milvus 索引或查询。向量费用由平台承担,不进入 one-api/Works Square 模型目录、用户用量、词元点数或智能体金额预算。下述既有受管模型能力链不作为云知识库 embedding 入口;聊天及其他原有网关能力保持。访问资格和实际运行时限仍由云执行检查。
+10
View File
@@ -4,6 +4,16 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
## Integrated Through
- 2026-09-21:任务 `20260921-merge-disabled-model-2209f56a` 经用户确认接管主目录,
将模型切换修复源 `0f7093d17338463c29f11e0d0b0d45bb9e2c2b52` 从 `d222d17`
无冲突快进合入本地 `main`。旧模型下架后可在原会话选择可用模型;尚无 worker
时先保存新选择再准备运行时,已崩溃目标使用新模型重建,保留历史与 session binding。
模型下架投影明确的 model-unavailable 提示,不自动选模、重发消息或强制停止运行任务。
产品树与已验证源一致,沿用 75 项相关单测、1 项 Electron 交互、标准类型检查、scoped lint
与 Vite 全目标构建;额外 Main 类型检查的 62 项既有诊断无新增。
源记录原样导入,3 份原有未跟踪文档保持;未推送、打包或部署。
详见[集成记录](tasks/20260921-merge-disabled-model-2209f56a.md)及[模型更换数据流](../20-architecture/data-flow.md#code-model-replacement)。
- 2026-09-20:任务 `20260920-merge-coding-sessions-bfb69ebb` 经用户确认释放旧集成登记并接管主目录,
将会话管理源 `fd0293fe3d94b5a7725cff992ca94f129a6ac889` 从 `d612265` 无冲突快进合入本地 `main`。
Code 支持归档/恢复及共享重命名入口;新会话使用首条用户消息首行作为标题,不调用模型,
@@ -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.
@@ -0,0 +1,45 @@
# Task: Integrate unavailable model switching into main
## Identity
- Task ID: 20260921-merge-disabled-model-2209f56a
- Mode: Integration
- Branch: main
- Worktree: D:\Datas\OthersProjects\makelore
- Base commit: d222d175000008bad5f344577679256d22a636c2
- Owner: codex
- Status: Ready for Integration
## Scope
- Integrate completed source 0f7093d17338463c29f11e0d0b0d45bb9e2c2b52 into local main and promote the removed-model replacement behavior.
## Intent And Constraints
- User explicitly approved releasing completed owner 20260920-merge-coding-sessions-bfb69ebb and adopting the three existing untracked documents unchanged. Official release/start/status passed; this task owns main and the exclusive integration lock at the recorded base.
- Concurrent Task Gate and Planning Gate Passed. Read the entry, own task, same-base integrated context, accepted ADR-006/model-capability authority, architecture/domain/success criteria and relevant evidence/reflection/commitment context. Read all 149 peer Scope/Intent/Promotion sections; historical placeholder scopes remain unknown/read-only without a concrete conflicting dependency. Updated audio work is independent; model-list sync and Windows shell remain separate branches.
- Plan: fast-forward the exact completed source, promote consistent recovery semantics, verify source-tree/document identity and documentation boundaries, then complete the integration. Reuse exact-source tests if no product changes are needed.
- Preserve active/uncertain work, explicit model choice, history and session identity. Source task records stay read-only. No subagents, remote push, packaging, installation or deployment.
## Outcome
- Fast-forwarded local main from d222d175000008bad5f344577679256d22a636c2 to 0f7093d17338463c29f11e0d0b0d45bb9e2c2b52 without conflicts. The exact completed source result is now on main.
- Promoted explicit dormant/crashed model replacement and actionable model-unavailable errors to data-flow, business-rules and current-state. These implement existing Main/metadata/Pi ownership and preserve active/uncertain work; no architecture reversal or conflicting source was selected.
- Original untracked documents 20260901-package-122-c5e8.md, 20260901-package-123-d7f3.md and 20260902-client-hang-diagnosis-a47c9e2b.md remain unchanged and outside the commit. Source task record imported unchanged.
## Verification
- Verified source branch/HEAD/readiness and clean source worktree, plus direct fast-forward ancestry. After merge, git diff --exit-code 0f7093d HEAD passed; product files and source record match the validated source exactly.
- Reuse exact-source verification because no product changes were made during integration: 75 relevant unit tests, 1 isolated Electron unavailable-model replacement scenario, standard typecheck, scoped ESLint and Vite Renderer/Main/Preload/utility build passed.
- Source comparison found 62 existing Main TypeScript errors and no additional diagnostics. Real provider inference, installed-client verification, installer smoke and full suite were not performed.
- Review and task-aware documentation drift check cover source import provenance, canonical changes and unchanged adopted files.
## Follow-ups
- User explicitly approved source cleanup. Before retirement, verified its exact resolved path, clean status, ready/managed ownership, committed task record, branch and latest HEAD included in main. Bundled task_context.py retire failed with Git "Directory not empty" at D:\Datas\OthersProjects\.codex-worktrees\makelore\20260921-disabled-model-switch-0e02d13a. Git removed the worktree registration but left partial files (including dependencies/reports and source files); task ownership remains because the script stopped before unregistering it. Branch codex/20260921-disabled-model-switch-0e02d13a-disabled-model-switch still points to 0f7093d and is preserved. Cleanup is blocked by the bundled lifecycle tool, not awaiting user permission; no manual registry edit, forced residual deletion, or simulated ownership recovery was attempted.
- Publish/install a client build to deliver the fix to installed users; this task only integrates local source. Earlier model-list and Windows shell tasks remain separate branches.
## Promotion Candidates
- Accepted and promoted the source model-replacement flow into data-flow/business-rules/current-state. The user authorized this fix's main integration; accepted Pi and provider capability authority remain unchanged. No remaining promotion candidate.
- Maintenance follow-up: bundled retire needs an explicit recovery protocol for Windows Git partial-removal failures; evidence and affected state are recorded in this task's reflection case. No skill change was made.
+1 -1
View File
@@ -144,7 +144,7 @@
- 模块置灰/路由阻断不是 API 授权边界。每个 Works/模块服务端 API 仍必须独立执行身份与权限检查;`/api/auth/me` 返回终止性 `401` 时必须清理 Main 和 Renderer 会话,不得以默认全开继续。
- Makelore Code 的唯一 production runtime 是精确 pin 的 Pi `0.84.2`。不得恢复 OpenCode fallback、RPC/SDK 双轨、兼容执行路径或 Renderer runtime 直连;产品公共合同必须保持 project/Agent/Conversation/Snapshot/Patch 中立,Pi wire 只属于 Main。
- project、Agent、Conversation 使用 `.makelore/project.json` 与 `.makelore/conversations.json` schema v2。Agent id、名称、原始 prompt、Skills 与 archive 状态必须稳定保存;不得从 `.niancode` 或 `.opencode` 读取或迁移项目元数据,也不得作为顺带清理删除这些用户内容。
- 每条 active/warm Conversation 在同一个长驻父 Agent Server 内绑定独立 Pi Runtime/Session/channel。首次本地 Conversation 创建和 Composer 编辑不得等待逻辑线程;未解析 Conversation 第一次选模必须先验证模型、持久化 resolved metadata,再 prepare。已解析且同账号的模型变化复用 target `set_model`,跨账号变化等 active run settled 后只重建目标逻辑线程。
- 每条 active/warm Conversation 在同一个长驻父 Agent Server 内绑定独立 Pi Runtime/Session/channel。首次本地 Conversation 创建和 Composer 编辑不得等待逻辑线程;未解析或尚无 worker 的 Conversation 选模必须先验证新模型、持久化 resolved metadata,再 prepare,不能要求旧模型仍可用。已有正常 worker 的同账号模型变化复用 target `set_model`;已崩溃 worker 用新选择重建,跨账号变化等 active run settled 后只重建目标逻辑线程。模型下架提示重新选择,显式更换保留原会话与历史,不自动选模或重发消息。
- 正式包中的父 Agent Server 必须从显式 staged `pi-runtime` manifest/root 定位 Pi 包及其导入入口,并验证入口仍位于目标包目录内;不得从脚本相邻资源目录、应用 `node_modules`、系统 npm 或网络下载回退解析。
- prompt、steer、follow-up、compact 等 mutation 必须先获得目标 Conversation 的 `202` acceptance/dedupe 结果。confirmation timeout 只表示 uncertain,不得自动重发,也不得释放 run permit、Agent Server/child process ownership 或 Main background lease;迟到 success/failure/exit/abort 必须单调、exactly-once 收敛。线程级失败只影响目标 Conversation;整个 Agent Server 退出时所有旧父 channel 一起 fail closed,但 Main/Renderer 继续存活且下次恢复只启动一个新 Server。
- Renderer 只消费 Snapshot-first 与 `patch-batch` SSE。每条 Conversation 的 generation/seq 独立;stale generation 丢弃,gap/reconnect 只恢复目标 Snapshot 并应用严格连续的缓冲 tail,不重放 mutation,也不改变乐观消息的 UI identity。
@@ -0,0 +1,7 @@
# Windows partial worktree retirement
- Date: 2026-09-21. Status: observed; cleanup blocked. Source: authorized retire invocation for task 20260921-disabled-model-switch-0e02d13a.
- Preconditions passed: exact target path, clean managed linked worktree, ready committed record, retained source branch and latest source result in main.
- Bundled retire invoked git worktree remove --force, which failed with Directory not empty. The Git worktree registration disappeared, but the directory retained source/dependency/report files and project-docs ownership remained. Main and the source branch retain commit 0f7093d.
- This repeats the partial-removal shape already recorded by earlier integrations. Ordinary retire cannot simply resume after Git has removed its registration; release-missing cannot apply while the directory remains.
- Candidate: add a bundled, explicit partial-retirement recovery protocol that checks retained source identity and handles residual cleanup and ownership coherently. Until available, keep this state visible and do not emulate ownership or bypass the lifecycle script. The user already authorized removing this exact directory; the remaining block is mechanical.
+2
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 Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。
@@ -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 {
@@ -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,
};
}
}
}
+2 -1
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 {
+45 -4
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);
+40
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);
@@ -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);